Public Access
M0 step 7: Settings, About, Storage, first-boot setup
- lib/apps_model (host-tested): SettingsMenu (rows, readable values, choices, validation messages) and SetupWizard (names, Region confirmation, timezone; saves only when finished) - AppManager: modal Apps that Home/Back can't leave (setup wizard) - SettingsApp: all settings plus Storage (usage, erase SD behind a dialog) and About (version, node id, battery, memory, uptime) pages, replacing the temporary Diagnostics App - SetupApp: first-boot wizard, opened modally until SetupDone - Node id and default names derived from the MAC like Meshtastic - Widget demo is now hidden (About, then w) - lib_ldf_mode = deep+ so libraries see each other's headers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
#include "settings_menu.h"
|
||||||
|
|
||||||
|
#include "choices.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Row = SettingsMenu::Row;
|
||||||
|
using Kind = SettingsMenu::Kind;
|
||||||
|
|
||||||
|
struct RowDef {
|
||||||
|
Row row;
|
||||||
|
Kind kind;
|
||||||
|
const char* label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RowDef kRows[] = {
|
||||||
|
{Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"},
|
||||||
|
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
|
||||||
|
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"},
|
||||||
|
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
|
||||||
|
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Storage, Kind::Page, "Storage"},
|
||||||
|
{Row::About, Kind::Page, "About"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const int kDimSeconds[] = {10, 15, 30, 60, 120, 300};
|
||||||
|
const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800};
|
||||||
|
|
||||||
|
std::string formatSeconds(int s) { return s < 60 ? std::to_string(s) + " s" : std::to_string(s / 60) + " min"; }
|
||||||
|
|
||||||
|
template <size_t N>
|
||||||
|
std::vector<std::string> labels(const Choice (&table)[N]) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (auto& c : table) out.push_back(c.label);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <size_t N>
|
||||||
|
std::vector<std::string> durations(const int (&seconds)[N]) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (int s : seconds) out.push_back(formatSeconds(s));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <size_t N>
|
||||||
|
int indexOf(const Choice (&table)[N], const std::string& value) {
|
||||||
|
for (size_t i = 0; i < N; i++)
|
||||||
|
if (value == table[i].value) return static_cast<int>(i);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <size_t N>
|
||||||
|
int indexOf(const int (&table)[N], int value) {
|
||||||
|
for (size_t i = 0; i < N; i++)
|
||||||
|
if (table[i] == value) return static_cast<int>(i);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int SettingsMenu::count() const { return sizeof(kRows) / sizeof(kRows[0]); }
|
||||||
|
SettingsMenu::Row SettingsMenu::row(int i) const { return kRows[i].row; }
|
||||||
|
SettingsMenu::Kind SettingsMenu::kind(int i) const { return kRows[i].kind; }
|
||||||
|
std::string SettingsMenu::label(int i) const { return kRows[i].label; }
|
||||||
|
|
||||||
|
std::string SettingsMenu::value(int i) const {
|
||||||
|
switch (row(i)) {
|
||||||
|
case Row::LongName: return settings_.getString(Setting::LongName);
|
||||||
|
case Row::ShortName: return settings_.getString(Setting::ShortName);
|
||||||
|
case Row::Region: return settings_.getString(Setting::Region);
|
||||||
|
case Row::Timezone: {
|
||||||
|
int c = currentChoice(i);
|
||||||
|
return c >= 0 ? kTimezones[c].label : "Custom";
|
||||||
|
}
|
||||||
|
case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%";
|
||||||
|
case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS));
|
||||||
|
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
|
||||||
|
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
|
||||||
|
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> SettingsMenu::choices(int i) const {
|
||||||
|
switch (row(i)) {
|
||||||
|
case Row::Region: return labels(kRegions);
|
||||||
|
case Row::Timezone: return labels(kTimezones);
|
||||||
|
case Row::DimTimeout: return durations(kDimSeconds);
|
||||||
|
case Row::OffTimeout: return durations(kOffSeconds);
|
||||||
|
default: return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsMenu::currentChoice(int i) const {
|
||||||
|
switch (row(i)) {
|
||||||
|
case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region));
|
||||||
|
case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone));
|
||||||
|
case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS));
|
||||||
|
case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS));
|
||||||
|
default: return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SettingsMenu::choose(int i, int c) {
|
||||||
|
switch (row(i)) {
|
||||||
|
case Row::Region:
|
||||||
|
settings_.setString(Setting::Region, kRegions[c].value);
|
||||||
|
settings_.setBool(Setting::RegionConfirmed, true);
|
||||||
|
return "";
|
||||||
|
case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return "";
|
||||||
|
case Row::DimTimeout:
|
||||||
|
return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off";
|
||||||
|
case Row::OffTimeout:
|
||||||
|
return settings_.setInt(Setting::OffTimeoutS, kOffSeconds[c]) ? "" : "Screen off must come after dimming";
|
||||||
|
default: return "Not a choice";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsMenu::toggle(int i) {
|
||||||
|
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound));
|
||||||
|
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsMenu::adjust(int i, int direction) {
|
||||||
|
if (row(i) != Row::Brightness) return;
|
||||||
|
int next = settings_.getInt(Setting::Brightness) + (direction > 0 ? 10 : -10);
|
||||||
|
if (next < 10) next = 10;
|
||||||
|
if (next > 100) next = 100;
|
||||||
|
settings_.setInt(Setting::Brightness, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsMenu::maxBytes(int i) const {
|
||||||
|
return row(i) == Row::ShortName ? 4 : row(i) == Row::LongName ? 39 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SettingsMenu::setText(int i, const std::string& text) {
|
||||||
|
Setting s = row(i) == Row::ShortName ? Setting::ShortName : Setting::LongName;
|
||||||
|
if (settings_.setString(s, text)) return "";
|
||||||
|
return "Must be 1 to " + std::to_string(maxBytes(i)) + " bytes";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// What the Settings App lists: one row per user-facing setting (plus sub-pages), with readable
|
||||||
|
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||||
|
class SettingsMenu {
|
||||||
|
public:
|
||||||
|
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Storage, About };
|
||||||
|
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||||
|
|
||||||
|
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
Row row(int i) const;
|
||||||
|
Kind kind(int i) const;
|
||||||
|
std::string label(int i) const;
|
||||||
|
std::string value(int i) const;
|
||||||
|
|
||||||
|
// Choice rows
|
||||||
|
std::vector<std::string> choices(int i) const;
|
||||||
|
int currentChoice(int i) const; // -1 if the current value isn't one of the choices
|
||||||
|
std::string choose(int i, int choice); // empty on success, otherwise why it was refused
|
||||||
|
|
||||||
|
// Toggle and Slider rows
|
||||||
|
void toggle(int i);
|
||||||
|
void adjust(int i, int direction);
|
||||||
|
|
||||||
|
// Text rows
|
||||||
|
int maxBytes(int i) const;
|
||||||
|
std::string setText(int i, const std::string& text); // empty on success, otherwise why
|
||||||
|
|
||||||
|
private:
|
||||||
|
Settings& settings_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#include "setup_wizard.h"
|
||||||
|
|
||||||
|
#include "choices.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
SetupWizard::SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName)
|
||||||
|
: settings_(settings), longName_(std::move(defaultLongName)), shortName_(std::move(defaultShortName)) {}
|
||||||
|
|
||||||
|
int SetupWizard::choiceCount() const {
|
||||||
|
if (step_ == Step::Region) return sizeof(kRegions) / sizeof(kRegions[0]);
|
||||||
|
if (step_ == Step::Timezone) return sizeof(kTimezones) / sizeof(kTimezones[0]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SetupWizard::choiceLabel(int i) const {
|
||||||
|
return step_ == Step::Region ? kRegions[i].label : kTimezones[i].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
int SetupWizard::selectedChoice() const { return step_ == Step::Region ? region_ : timezone_; }
|
||||||
|
|
||||||
|
void SetupWizard::selectChoice(int i) {
|
||||||
|
if (i < 0 || i >= choiceCount()) return;
|
||||||
|
(step_ == Step::Region ? region_ : timezone_) = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetupWizard::enter(Step step) {
|
||||||
|
step_ = step;
|
||||||
|
error_.clear();
|
||||||
|
if (step == Step::LongName) {
|
||||||
|
editor_ = LineEditor(39);
|
||||||
|
editor_.setText(longName_);
|
||||||
|
} else if (step == Step::ShortName) {
|
||||||
|
editor_ = LineEditor(4);
|
||||||
|
editor_.setText(shortName_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SetupWizard::next() {
|
||||||
|
switch (step_) {
|
||||||
|
case Step::Welcome: enter(Step::LongName); return true;
|
||||||
|
case Step::LongName:
|
||||||
|
if (editor_.text().empty()) {
|
||||||
|
error_ = "Please enter a name";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
longName_ = editor_.text();
|
||||||
|
enter(Step::ShortName);
|
||||||
|
return true;
|
||||||
|
case Step::ShortName:
|
||||||
|
if (editor_.text().empty()) {
|
||||||
|
error_ = "Please enter 1 to 4 characters";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
shortName_ = editor_.text();
|
||||||
|
enter(Step::Region);
|
||||||
|
return true;
|
||||||
|
case Step::Region: enter(Step::Timezone); return true;
|
||||||
|
case Step::Timezone: enter(Step::Done); return true;
|
||||||
|
case Step::Done:
|
||||||
|
save();
|
||||||
|
finished_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetupWizard::back() {
|
||||||
|
switch (step_) {
|
||||||
|
case Step::LongName: longName_ = editor_.text(); enter(Step::Welcome); break;
|
||||||
|
case Step::ShortName: shortName_ = editor_.text(); enter(Step::LongName); break;
|
||||||
|
case Step::Region: enter(Step::ShortName); break;
|
||||||
|
case Step::Timezone: enter(Step::Region); break;
|
||||||
|
case Step::Done: enter(Step::Timezone); break;
|
||||||
|
case Step::Welcome: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetupWizard::save() {
|
||||||
|
settings_.setString(Setting::LongName, longName_);
|
||||||
|
settings_.setString(Setting::ShortName, shortName_);
|
||||||
|
settings_.setString(Setting::Region, kRegions[region_].value);
|
||||||
|
settings_.setString(Setting::Timezone, kTimezones[timezone_].value);
|
||||||
|
settings_.setBool(Setting::RegionConfirmed, true);
|
||||||
|
settings_.setBool(Setting::SetupDone, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "line_editor.h"
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// The first-boot setup flow: names, Region confirmation, timezone. Nothing is saved until the
|
||||||
|
// last step, so an interrupted setup simply starts again at the next boot.
|
||||||
|
class SetupWizard {
|
||||||
|
public:
|
||||||
|
enum class Step { Welcome, LongName, ShortName, Region, Timezone, Done };
|
||||||
|
|
||||||
|
SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName);
|
||||||
|
|
||||||
|
Step step() const { return step_; }
|
||||||
|
bool finished() const { return finished_; }
|
||||||
|
|
||||||
|
// Name steps edit text; Region and Timezone steps pick a choice.
|
||||||
|
LineEditor& editor() { return editor_; }
|
||||||
|
int choiceCount() const;
|
||||||
|
std::string choiceLabel(int i) const;
|
||||||
|
int selectedChoice() const;
|
||||||
|
void selectChoice(int i);
|
||||||
|
|
||||||
|
bool next(); // validates the step; false (with error()) if it can't advance
|
||||||
|
void back();
|
||||||
|
const std::string& error() const { return error_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void enter(Step step);
|
||||||
|
void save();
|
||||||
|
|
||||||
|
Settings& settings_;
|
||||||
|
Step step_ = Step::Welcome;
|
||||||
|
bool finished_ = false;
|
||||||
|
std::string error_;
|
||||||
|
LineEditor editor_{39};
|
||||||
|
std::string longName_, shortName_;
|
||||||
|
int region_ = 0;
|
||||||
|
int timezone_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -21,19 +21,41 @@ void AppManager::begin() {
|
|||||||
redraw_ = true;
|
redraw_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
App* AppManager::find(const char* id) const {
|
||||||
|
for (auto& info : apps_)
|
||||||
|
if (std::strcmp(info.id, id) == 0) return info.app;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool AppManager::open(const char* id) {
|
bool AppManager::open(const char* id) {
|
||||||
for (auto& info : apps_) {
|
App* app = find(id);
|
||||||
if (std::strcmp(info.id, id) == 0) {
|
if (!app || modal_) return false;
|
||||||
switchTo(*info.app);
|
switchTo(*app);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
void AppManager::home() {
|
||||||
|
if (!modal_) switchTo(launcher_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void AppManager::home() { switchTo(launcher_); }
|
bool AppManager::openModal(const char* id) {
|
||||||
|
App* app = find(id);
|
||||||
|
if (!app) return false;
|
||||||
|
switchTo(*app);
|
||||||
|
modal_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppManager::endModal() {
|
||||||
|
modal_ = false;
|
||||||
|
switchTo(launcher_);
|
||||||
|
}
|
||||||
|
|
||||||
void AppManager::handleKey(const KeyEvent& event) {
|
void AppManager::handleKey(const KeyEvent& event) {
|
||||||
|
if (modal_) {
|
||||||
|
foreground_->onKey(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key == Key::Home) {
|
if (event.key == Key::Home) {
|
||||||
home();
|
home();
|
||||||
return;
|
return;
|
||||||
@@ -42,6 +64,12 @@ void AppManager::handleKey(const KeyEvent& event) {
|
|||||||
if (!consumed && event.key == Key::Back) home();
|
if (!consumed && event.key == Key::Back) home();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char* AppManager::foregroundTitle() const {
|
||||||
|
for (auto& info : apps_)
|
||||||
|
if (info.app == foreground_) return info.title;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool AppManager::takeRedraw() {
|
bool AppManager::takeRedraw() {
|
||||||
bool r = redraw_ | foreground_->consumeRedraw();
|
bool r = redraw_ | foreground_->consumeRedraw();
|
||||||
redraw_ = false;
|
redraw_ = false;
|
||||||
|
|||||||
@@ -23,24 +23,32 @@ class AppManager {
|
|||||||
std::vector<const AppInfo*> visibleApps() const;
|
std::vector<const AppInfo*> visibleApps() const;
|
||||||
|
|
||||||
void begin();
|
void begin();
|
||||||
bool open(const char* id);
|
bool open(const char* id); // refused while a modal App runs
|
||||||
void home();
|
void home();
|
||||||
|
|
||||||
|
// A modal App (e.g. the first-boot wizard) can't be left with Home or Back: those keys go to it,
|
||||||
|
// and it ends itself with endModal(), which returns to the Launcher.
|
||||||
|
bool openModal(const char* id);
|
||||||
|
void endModal();
|
||||||
|
|
||||||
void handleKey(const KeyEvent& event);
|
void handleKey(const KeyEvent& event);
|
||||||
void update(uint32_t nowMs) { foreground_->update(nowMs); }
|
void update(uint32_t nowMs) { foreground_->update(nowMs); }
|
||||||
|
|
||||||
App& foreground() const { return *foreground_; }
|
App& foreground() const { return *foreground_; }
|
||||||
|
const char* foregroundTitle() const; // nullptr for the Launcher
|
||||||
|
|
||||||
// True once after the screen needs redrawing (App switch, or the App asked for it).
|
// True once after the screen needs redrawing (App switch, or the App asked for it).
|
||||||
bool takeRedraw();
|
bool takeRedraw();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void switchTo(App& app);
|
void switchTo(App& app);
|
||||||
|
App* find(const char* id) const;
|
||||||
|
|
||||||
App& launcher_;
|
App& launcher_;
|
||||||
App* foreground_;
|
App* foreground_;
|
||||||
std::vector<AppInfo> apps_;
|
std::vector<AppInfo> apps_;
|
||||||
bool redraw_ = true;
|
bool redraw_ = true;
|
||||||
|
bool modal_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace roro
|
} // namespace roro
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
struct Choice {
|
||||||
|
const char* label;
|
||||||
|
const char* value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timezones offered in Settings and the setup wizard (POSIX TZ strings).
|
||||||
|
inline const Choice kTimezones[] = {
|
||||||
|
{"Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, // also Paris, Amsterdam, Berlin...
|
||||||
|
{"London", "GMT0BST,M3.5.0/1,M10.5.0"},
|
||||||
|
{"Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"},
|
||||||
|
{"Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"},
|
||||||
|
{"UTC", "UTC0"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Regions the radio may be used in (see Region in CONTEXT.md).
|
||||||
|
inline const Choice kRegions[] = {
|
||||||
|
{"EU868 (Europe, 869.5 MHz)", "EU868"},
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -7,6 +7,7 @@ default_envs = cardputer-adv
|
|||||||
[env]
|
[env]
|
||||||
extra_scripts = pre:scripts/version.py
|
extra_scripts = pre:scripts/version.py
|
||||||
test_framework = unity
|
test_framework = unity
|
||||||
|
lib_ldf_mode = deep+
|
||||||
|
|
||||||
[env:cardputer-adv]
|
[env:cardputer-adv]
|
||||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
#include "diagnostics_app.h"
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
|
|
||||||
#include "ui/fonts.h"
|
|
||||||
#include "ui/widgets.h"
|
|
||||||
#include "version.h"
|
|
||||||
|
|
||||||
namespace roro {
|
|
||||||
|
|
||||||
bool DiagnosticsApp::onKey(const KeyEvent& e) {
|
|
||||||
requestRedraw();
|
|
||||||
if (formatDialog_) {
|
|
||||||
formatDialog_->onKey(e);
|
|
||||||
if (formatDialog_->result() == 1) storage_.requestFormat();
|
|
||||||
if (formatDialog_->result() != DialogModel::kPending) formatDialog_.reset();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key != Key::Char) return false;
|
|
||||||
int brightness = settings_.getInt(Setting::Brightness);
|
|
||||||
switch (e.ch) {
|
|
||||||
case '+':
|
|
||||||
case '=': settings_.setInt(Setting::Brightness, brightness + 10); return true;
|
|
||||||
case '-': settings_.setInt(Setting::Brightness, brightness - 10); return true;
|
|
||||||
case 'd':
|
|
||||||
settings_.setInt(Setting::DimTimeoutS, 5);
|
|
||||||
settings_.setInt(Setting::OffTimeoutS, 10);
|
|
||||||
return true;
|
|
||||||
case 'r': // restore default timeouts
|
|
||||||
settings_.setInt(Setting::OffTimeoutS, 60);
|
|
||||||
settings_.setInt(Setting::DimTimeoutS, 30);
|
|
||||||
return true;
|
|
||||||
case 'f':
|
|
||||||
if (storage_.state().present) formatDialog_.reset(new DialogModel({"Cancel", "Erase"}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void DiagnosticsApp::update(uint32_t nowMs) {
|
|
||||||
if (nowMs - lastRefreshMs_ >= 1000) {
|
|
||||||
lastRefreshMs_ = nowMs;
|
|
||||||
requestRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DiagnosticsApp::draw(Canvas& c) {
|
|
||||||
const auto& area = theme::kContent;
|
|
||||||
const auto& b = battery_.estimator();
|
|
||||||
const auto s = storage_.state();
|
|
||||||
std::vector<std::string> lines;
|
|
||||||
char buf[64];
|
|
||||||
snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString());
|
|
||||||
lines.push_back(buf);
|
|
||||||
snprintf(buf, sizeof(buf), "Battery %d%% %d mV (raw %d)", b.percent(), b.millivolts(), battery_.lastRawMillivolts());
|
|
||||||
lines.push_back(buf);
|
|
||||||
if (storage_.formatting())
|
|
||||||
lines.push_back("SD: formatting...");
|
|
||||||
else if (s.present) {
|
|
||||||
snprintf(buf, sizeof(buf), "SD %d%% of %u MB logs:%s capt:%s", s.usedPercent,
|
|
||||||
static_cast<unsigned>(s.totalBytes >> 20), s.logsAllowed ? "y" : "n", s.capturesAllowed ? "y" : "n");
|
|
||||||
lines.push_back(buf);
|
|
||||||
} else
|
|
||||||
lines.push_back("SD: no card");
|
|
||||||
snprintf(buf, sizeof(buf), "Uptime %lus clock %s", millis() / 1000, clock_.displayTime().c_str());
|
|
||||||
lines.push_back(buf);
|
|
||||||
snprintf(buf, sizeof(buf), "Heap %u KB free, min %u KB", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024);
|
|
||||||
lines.push_back(buf);
|
|
||||||
snprintf(buf, sizeof(buf), "Bright %d%% dim %ds off %ds", static_cast<int>(settings_.getInt(Setting::Brightness)),
|
|
||||||
static_cast<int>(settings_.getInt(Setting::DimTimeoutS)),
|
|
||||||
static_cast<int>(settings_.getInt(Setting::OffTimeoutS)));
|
|
||||||
lines.push_back(buf);
|
|
||||||
widgets::textLines(c, lines, 0, area);
|
|
||||||
|
|
||||||
c.setFont(&fonts::small);
|
|
||||||
c.setTextColor(theme::kMuted);
|
|
||||||
c.drawString("+/- bright d: 5s/10s r: 30s/60s f: erase SD", 4, area.y + area.h - 10);
|
|
||||||
|
|
||||||
if (formatDialog_)
|
|
||||||
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
|
|
||||||
*formatDialog_);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace roro
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
#include "app.h"
|
|
||||||
#include "dialog_model.h"
|
|
||||||
#include "services/battery_service.h"
|
|
||||||
#include "services/clock_service.h"
|
|
||||||
#include "services/power_service.h"
|
|
||||||
#include "services/storage_service.h"
|
|
||||||
#include "settings.h"
|
|
||||||
|
|
||||||
namespace roro {
|
|
||||||
|
|
||||||
// Live hardware values for bring-up; becomes the About screen in step 7.
|
|
||||||
class DiagnosticsApp : public App {
|
|
||||||
public:
|
|
||||||
DiagnosticsApp(Settings& settings, BatteryService& battery, StorageService& storage, ClockService& clock)
|
|
||||||
: settings_(settings), battery_(battery), storage_(storage), clock_(clock) {}
|
|
||||||
bool onKey(const KeyEvent& e) override;
|
|
||||||
void update(uint32_t nowMs) override;
|
|
||||||
void draw(Canvas& c) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
Settings& settings_;
|
|
||||||
BatteryService& battery_;
|
|
||||||
StorageService& storage_;
|
|
||||||
ClockService& clock_;
|
|
||||||
std::unique_ptr<DialogModel> formatDialog_;
|
|
||||||
uint32_t lastRefreshMs_ = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace roro
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#include "settings_app.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "platform/identity.h"
|
||||||
|
#include "ui/fonts.h"
|
||||||
|
#include "ui/widgets.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
using Kind = SettingsMenu::Kind;
|
||||||
|
using Row = SettingsMenu::Row;
|
||||||
|
|
||||||
|
void SettingsApp::onEnter() {
|
||||||
|
page_ = Page::Menu;
|
||||||
|
eraseDialog_.reset();
|
||||||
|
list_.setCount(menu_.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsApp::warn(const std::string& text) {
|
||||||
|
d_.bus.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onKey(const KeyEvent& e) {
|
||||||
|
requestRedraw();
|
||||||
|
switch (page_) {
|
||||||
|
case Page::Menu: return onMenuKey(e);
|
||||||
|
case Page::Text: return onTextKey(e);
|
||||||
|
case Page::Choice: return onChoiceKey(e);
|
||||||
|
case Page::Storage: return onStorageKey(e);
|
||||||
|
case Page::About: return onAboutKey(e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
||||||
|
int i = list_.selected();
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Up: list_.up(); return true;
|
||||||
|
case Key::Down: list_.down(); return true;
|
||||||
|
case Key::Left:
|
||||||
|
case Key::Right:
|
||||||
|
if (menu_.kind(i) == Kind::Slider) menu_.adjust(i, e.key == Key::Right ? 1 : -1);
|
||||||
|
if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i);
|
||||||
|
return true;
|
||||||
|
case Key::Select: break;
|
||||||
|
default: return false; // Back leaves Settings
|
||||||
|
}
|
||||||
|
editingRow_ = i;
|
||||||
|
switch (menu_.kind(i)) {
|
||||||
|
case Kind::Text:
|
||||||
|
editor_ = LineEditor(menu_.maxBytes(i));
|
||||||
|
editor_.setText(menu_.value(i));
|
||||||
|
page_ = Page::Text;
|
||||||
|
break;
|
||||||
|
case Kind::Choice:
|
||||||
|
choices_.setCount(static_cast<int>(menu_.choices(i).size()));
|
||||||
|
choices_.select(menu_.currentChoice(i) < 0 ? 0 : menu_.currentChoice(i));
|
||||||
|
page_ = Page::Choice;
|
||||||
|
break;
|
||||||
|
case Kind::Toggle: menu_.toggle(i); break;
|
||||||
|
case Kind::Slider: break;
|
||||||
|
case Kind::Page: page_ = menu_.row(i) == Row::Storage ? Page::Storage : Page::About; break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onTextKey(const KeyEvent& e) {
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Char: editor_.insert(e.ch); break;
|
||||||
|
case Key::Delete: editor_.backspace(); break;
|
||||||
|
case Key::Left: editor_.left(); break;
|
||||||
|
case Key::Right: editor_.right(); break;
|
||||||
|
case Key::Select: {
|
||||||
|
std::string error = menu_.setText(editingRow_, editor_.text());
|
||||||
|
if (error.empty()) page_ = Page::Menu;
|
||||||
|
else warn(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Key::Back: page_ = Page::Menu; break; // cancel
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onChoiceKey(const KeyEvent& e) {
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Up: choices_.up(); break;
|
||||||
|
case Key::Down: choices_.down(); break;
|
||||||
|
case Key::Select: {
|
||||||
|
std::string error = menu_.choose(editingRow_, choices_.selected());
|
||||||
|
if (error.empty()) page_ = Page::Menu;
|
||||||
|
else warn(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Key::Back: page_ = Page::Menu; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onStorageKey(const KeyEvent& e) {
|
||||||
|
if (eraseDialog_) {
|
||||||
|
eraseDialog_->onKey(e);
|
||||||
|
if (eraseDialog_->result() == 1 && !d_.storage.requestFormat()) warn("A format is already running");
|
||||||
|
if (eraseDialog_->result() != DialogModel::kPending) eraseDialog_.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key == Key::Select) {
|
||||||
|
if (d_.storage.state().present) eraseDialog_.reset(new DialogModel({"Cancel", "Erase"}));
|
||||||
|
else warn("No SD card");
|
||||||
|
}
|
||||||
|
if (e.key == Key::Back) page_ = Page::Menu;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
||||||
|
if (e.key == Key::Back) page_ = Page::Menu;
|
||||||
|
if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsApp::update(uint32_t nowMs) {
|
||||||
|
// Live values on Storage and About.
|
||||||
|
if ((page_ == Page::Storage || page_ == Page::About) && nowMs - lastRefreshMs_ >= 1000) {
|
||||||
|
lastRefreshMs_ = nowMs;
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> SettingsApp::storageLines() const {
|
||||||
|
auto s = d_.storage.state();
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
char buf[64];
|
||||||
|
if (d_.storage.formatting()) {
|
||||||
|
lines.push_back("Erasing the SD card...");
|
||||||
|
} else if (!s.present) {
|
||||||
|
lines.push_back("No SD card inserted.");
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), "SD card: %u MB, %d%% used", static_cast<unsigned>(s.totalBytes >> 20), s.usedPercent);
|
||||||
|
lines.push_back(buf);
|
||||||
|
lines.push_back(s.logsAllowed ? "Logs: recording" : "Logs: paused (over 90%)");
|
||||||
|
lines.push_back(s.capturesAllowed ? "Captures: allowed" : "Captures: stopped (card full)");
|
||||||
|
}
|
||||||
|
lines.push_back("");
|
||||||
|
lines.push_back("Enter: erase SD card");
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> SettingsApp::aboutLines() const {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString());
|
||||||
|
lines.push_back(buf);
|
||||||
|
lines.push_back("Node " + identity::nodeId());
|
||||||
|
const auto& b = d_.battery.estimator();
|
||||||
|
snprintf(buf, sizeof(buf), "Battery %d%% (%d mV)", b.percent(), b.millivolts());
|
||||||
|
lines.push_back(buf);
|
||||||
|
snprintf(buf, sizeof(buf), "Memory %u KB free (min %u KB)", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024);
|
||||||
|
lines.push_back(buf);
|
||||||
|
uint32_t up = millis() / 1000;
|
||||||
|
snprintf(buf, sizeof(buf), "Uptime %luh %02lum %02lus", (unsigned long)(up / 3600), (unsigned long)(up / 60 % 60),
|
||||||
|
(unsigned long)(up % 60));
|
||||||
|
lines.push_back(buf);
|
||||||
|
lines.push_back("Clock " + d_.clock.displayTime());
|
||||||
|
lines.push_back("License GPL-3.0");
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsApp::draw(Canvas& c) {
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
switch (page_) {
|
||||||
|
case Page::Menu:
|
||||||
|
widgets::list(
|
||||||
|
c, list_, area, [this](int i) { return menu_.label(i); },
|
||||||
|
[this](int i) {
|
||||||
|
auto kind = menu_.kind(i);
|
||||||
|
if (kind == Kind::Page) return std::string(">");
|
||||||
|
if (kind == Kind::Slider) return "< " + menu_.value(i) + " >";
|
||||||
|
return menu_.value(i);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case Page::Text:
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4);
|
||||||
|
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
|
||||||
|
c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" +
|
||||||
|
std::to_string(editor_.maxBytes()) + " bytes")
|
||||||
|
.c_str(),
|
||||||
|
4, area.y + 44);
|
||||||
|
break;
|
||||||
|
case Page::Choice: {
|
||||||
|
auto options = menu_.choices(editingRow_);
|
||||||
|
int current = menu_.currentChoice(editingRow_);
|
||||||
|
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Page::Storage:
|
||||||
|
widgets::textLines(c, storageLines(), 0, area);
|
||||||
|
if (eraseDialog_)
|
||||||
|
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
|
||||||
|
*eraseDialog_);
|
||||||
|
break;
|
||||||
|
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
#include "app_manager.h"
|
||||||
|
#include "dialog_model.h"
|
||||||
|
#include "event_bus.h"
|
||||||
|
#include "line_editor.h"
|
||||||
|
#include "list_model.h"
|
||||||
|
#include "services/battery_service.h"
|
||||||
|
#include "services/clock_service.h"
|
||||||
|
#include "services/storage_service.h"
|
||||||
|
#include "settings_menu.h"
|
||||||
|
#include "ui/theme.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
struct SettingsAppDeps {
|
||||||
|
Settings& settings;
|
||||||
|
EventBus& bus;
|
||||||
|
AppManager& apps;
|
||||||
|
BatteryService& battery;
|
||||||
|
StorageService& storage;
|
||||||
|
ClockService& clock;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings: every user-facing setting, plus the Storage and About pages.
|
||||||
|
class SettingsApp : public App {
|
||||||
|
public:
|
||||||
|
explicit SettingsApp(const SettingsAppDeps& deps) : d_(deps), menu_(deps.settings) {}
|
||||||
|
void onEnter() override;
|
||||||
|
bool onKey(const KeyEvent& e) override;
|
||||||
|
void update(uint32_t nowMs) override;
|
||||||
|
void draw(Canvas& c) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Page { Menu, Text, Choice, Storage, About };
|
||||||
|
|
||||||
|
bool onMenuKey(const KeyEvent& e);
|
||||||
|
bool onTextKey(const KeyEvent& e);
|
||||||
|
bool onChoiceKey(const KeyEvent& e);
|
||||||
|
bool onStorageKey(const KeyEvent& e);
|
||||||
|
bool onAboutKey(const KeyEvent& e);
|
||||||
|
void warn(const std::string& text);
|
||||||
|
std::vector<std::string> storageLines() const;
|
||||||
|
std::vector<std::string> aboutLines() const;
|
||||||
|
|
||||||
|
SettingsAppDeps d_;
|
||||||
|
SettingsMenu menu_;
|
||||||
|
Page page_ = Page::Menu;
|
||||||
|
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||||
|
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
||||||
|
LineEditor editor_{39};
|
||||||
|
std::unique_ptr<DialogModel> eraseDialog_;
|
||||||
|
int editingRow_ = 0;
|
||||||
|
uint32_t lastRefreshMs_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#include "setup_app.h"
|
||||||
|
|
||||||
|
#include "platform/identity.h"
|
||||||
|
#include "ui/fonts.h"
|
||||||
|
#include "ui/widgets.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
using Step = SetupWizard::Step;
|
||||||
|
|
||||||
|
SetupApp::SetupApp(Settings& settings, AppManager& apps)
|
||||||
|
: apps_(apps), wizard_(settings, identity::defaultLongName(), identity::defaultShortName()) {}
|
||||||
|
|
||||||
|
bool SetupApp::onKey(const KeyEvent& e) {
|
||||||
|
requestRedraw();
|
||||||
|
bool choosing = wizard_.choiceCount() > 0;
|
||||||
|
bool typing = wizard_.step() == Step::LongName || wizard_.step() == Step::ShortName;
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Select:
|
||||||
|
if (choosing) wizard_.selectChoice(choices_.selected());
|
||||||
|
if (wizard_.next()) {
|
||||||
|
if (wizard_.finished()) {
|
||||||
|
apps_.endModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
choices_.setCount(wizard_.choiceCount());
|
||||||
|
if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Key::Back:
|
||||||
|
case Key::Home:
|
||||||
|
wizard_.back();
|
||||||
|
choices_.setCount(wizard_.choiceCount());
|
||||||
|
if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice());
|
||||||
|
break;
|
||||||
|
case Key::Up: if (choosing) choices_.up(); break;
|
||||||
|
case Key::Down: if (choosing) choices_.down(); break;
|
||||||
|
case Key::Left: if (typing) wizard_.editor().left(); break;
|
||||||
|
case Key::Right: if (typing) wizard_.editor().right(); break;
|
||||||
|
case Key::Delete: if (typing) wizard_.editor().backspace(); break;
|
||||||
|
case Key::Char: if (typing) wizard_.editor().insert(e.ch); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetupApp::draw(Canvas& c) {
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
auto heading = [&](const char* text) {
|
||||||
|
c.setFont(&fonts::bold);
|
||||||
|
c.setTextColor(theme::kAccent);
|
||||||
|
c.drawString(text, 4, area.y + 3);
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
};
|
||||||
|
auto note = [&](const std::string& text, int y, uint16_t color = theme::kMuted) {
|
||||||
|
auto lines = wrapText(text, area.w - 8, widgets::bodyMeasure(c));
|
||||||
|
c.setTextColor(color);
|
||||||
|
for (size_t i = 0; i < lines.size(); i++) c.drawString(lines[i].c_str(), 4, y + i * theme::kLineHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (wizard_.step()) {
|
||||||
|
case Step::Welcome:
|
||||||
|
heading("Welcome to roro9stack");
|
||||||
|
note("Let's set up this device: your names on the mesh, your radio region and your timezone.",
|
||||||
|
area.y + 22, theme::kText);
|
||||||
|
note("Enter: continue", area.y + area.h - 14);
|
||||||
|
break;
|
||||||
|
case Step::LongName:
|
||||||
|
heading("Long name (1/4)");
|
||||||
|
note("Shown to other mesh users. Up to 39 bytes.", area.y + 20);
|
||||||
|
widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0});
|
||||||
|
break;
|
||||||
|
case Step::ShortName:
|
||||||
|
heading("Short name (2/4)");
|
||||||
|
note("Up to 4 characters, shown where space is tight.", area.y + 20);
|
||||||
|
widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0});
|
||||||
|
break;
|
||||||
|
case Step::Region:
|
||||||
|
heading("Radio region (3/4)");
|
||||||
|
note("The radio only transmits once you confirm your region.", area.y + 18);
|
||||||
|
widgets::list(c, choices_, {0, area.y + 48, area.w, 4 * theme::kLineHeight},
|
||||||
|
[this](int i) { return wizard_.choiceLabel(i); });
|
||||||
|
note("Enter: I confirm I'm in this region", area.y + area.h - 14);
|
||||||
|
break;
|
||||||
|
case Step::Timezone:
|
||||||
|
heading("Timezone (4/4)");
|
||||||
|
widgets::list(c, choices_, {0, area.y + 20, area.w, 4 * theme::kLineHeight},
|
||||||
|
[this](int i) { return wizard_.choiceLabel(i); });
|
||||||
|
break;
|
||||||
|
case Step::Done:
|
||||||
|
heading("All set");
|
||||||
|
note("You can change all of this later in Settings.", area.y + 22, theme::kText);
|
||||||
|
note("Enter: start `: go back", area.y + area.h - 14);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!wizard_.error().empty()) note(wizard_.error(), area.y + area.h - 14, theme::kWarning);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
#include "app_manager.h"
|
||||||
|
#include "list_model.h"
|
||||||
|
#include "setup_wizard.h"
|
||||||
|
#include "ui/theme.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// First-boot setup, run as a modal App until finished.
|
||||||
|
class SetupApp : public App {
|
||||||
|
public:
|
||||||
|
SetupApp(Settings& settings, AppManager& apps);
|
||||||
|
bool onKey(const KeyEvent& e) override;
|
||||||
|
void draw(Canvas& c) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
AppManager& apps_;
|
||||||
|
SetupWizard wizard_;
|
||||||
|
ListModel choices_{4};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
+8
-6
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
#include "app_manager.h"
|
#include "app_manager.h"
|
||||||
#include "apps/demo_app.h"
|
#include "apps/demo_app.h"
|
||||||
#include "apps/diagnostics_app.h"
|
|
||||||
#include "apps/launcher_app.h"
|
#include "apps/launcher_app.h"
|
||||||
|
#include "apps/settings_app.h"
|
||||||
|
#include "apps/setup_app.h"
|
||||||
#include "event_bus.h"
|
#include "event_bus.h"
|
||||||
#include "key_mapper.h"
|
#include "key_mapper.h"
|
||||||
#include "platform/nvs_store.h"
|
#include "platform/nvs_store.h"
|
||||||
@@ -54,8 +55,7 @@ static RawKeys readKeys() {
|
|||||||
static StatusInfo currentStatus() {
|
static StatusInfo currentStatus() {
|
||||||
StatusInfo s;
|
StatusInfo s;
|
||||||
s.title = kProductName;
|
s.title = kProductName;
|
||||||
for (auto* info : apps->visibleApps())
|
if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
|
||||||
if (info->app == &apps->foreground()) s.title = info->title;
|
|
||||||
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
|
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
|
||||||
s.clock = clockService->displayTime();
|
s.clock = clockService->displayTime();
|
||||||
auto storageState = storage->state();
|
auto storageState = storage->state();
|
||||||
@@ -87,15 +87,17 @@ void setup() {
|
|||||||
|
|
||||||
apps = new AppManager(launcher);
|
apps = new AppManager(launcher);
|
||||||
launcher.setManager(*apps);
|
launcher.setManager(*apps);
|
||||||
apps->registerApp({"demo", "Widget demo", false, new DemoApp(bus)});
|
apps->registerApp({"settings", "Settings", false,
|
||||||
apps->registerApp({"diagnostics", "Diagnostics", false,
|
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})});
|
||||||
new DiagnosticsApp(settings, *battery, *storage, *clockService)});
|
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||||
|
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
||||||
|
|
||||||
bus.subscribe(EventType::Notification, [](const Event& e) { Serial.printf("notification: %s\n", e.text); });
|
bus.subscribe(EventType::Notification, [](const Event& e) { Serial.printf("notification: %s\n", e.text); });
|
||||||
|
|
||||||
if (!screen.begin()) Serial.println("frame buffer allocation failed");
|
if (!screen.begin()) Serial.println("frame buffer allocation failed");
|
||||||
services.startAll(millis());
|
services.startAll(millis());
|
||||||
apps->begin();
|
apps->begin();
|
||||||
|
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
|
||||||
Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap());
|
Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace roro::identity {
|
||||||
|
|
||||||
|
// Node number as Meshtastic derives it: the last four bytes of the chip's MAC address.
|
||||||
|
inline uint32_t nodeNum() {
|
||||||
|
uint64_t mac = ESP.getEfuseMac(); // byte 0 of the MAC is the lowest byte
|
||||||
|
auto byte = [mac](int i) { return static_cast<uint32_t>((mac >> (8 * i)) & 0xFF); };
|
||||||
|
return (byte(2) << 24) | (byte(3) << 16) | (byte(4) << 8) | byte(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "!2f762fa4"
|
||||||
|
inline std::string nodeId() {
|
||||||
|
char buf[12];
|
||||||
|
std::snprintf(buf, sizeof(buf), "!%08lx", static_cast<unsigned long>(nodeNum()));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggested names for the setup wizard: "roro9stack 2fa4" and "2fa4".
|
||||||
|
inline std::string defaultShortName() {
|
||||||
|
char buf[8];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%04lx", static_cast<unsigned long>(nodeNum() & 0xFFFF));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
inline std::string defaultLongName() { return "roro9stack " + defaultShortName(); }
|
||||||
|
|
||||||
|
} // namespace roro::identity
|
||||||
+8
-1
@@ -36,7 +36,8 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
|||||||
c.setTextDatum(top_left);
|
c.setTextDatum(top_left);
|
||||||
}
|
}
|
||||||
|
|
||||||
void list(Canvas& c, const ListModel& model, Rect area, const std::function<std::string(int)>& label) {
|
void list(Canvas& c, const ListModel& model, Rect area, const std::function<std::string(int)>& label,
|
||||||
|
const std::function<std::string(int)>& value) {
|
||||||
c.setFont(&fonts::body);
|
c.setFont(&fonts::body);
|
||||||
c.setTextDatum(top_left);
|
c.setTextDatum(top_left);
|
||||||
int rows = model.visibleRows();
|
int rows = model.visibleRows();
|
||||||
@@ -48,6 +49,12 @@ void list(Canvas& c, const ListModel& model, Rect area, const std::function<std:
|
|||||||
if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection);
|
if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection);
|
||||||
c.setTextColor(kText);
|
c.setTextColor(kText);
|
||||||
c.drawString(label(i).c_str(), area.x + 4, y + 1);
|
c.drawString(label(i).c_str(), area.x + 4, y + 1);
|
||||||
|
if (value) {
|
||||||
|
c.setTextColor(selected ? kText : kMuted);
|
||||||
|
c.setTextDatum(top_right);
|
||||||
|
c.drawString(value(i).c_str(), area.x + area.w - 8, y + 1);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Scrollbar
|
// Scrollbar
|
||||||
if (model.count() > rows) {
|
if (model.count() > rows) {
|
||||||
|
|||||||
+3
-2
@@ -36,8 +36,9 @@ MeasureText bodyMeasure(Canvas& canvas);
|
|||||||
|
|
||||||
void statusBar(Canvas& c, const StatusInfo& info);
|
void statusBar(Canvas& c, const StatusInfo& info);
|
||||||
|
|
||||||
// A selectable list; `label(i)` gives row i's text.
|
// A selectable list; `label(i)` gives row i's text and `value(i)`, if given, a right-aligned value.
|
||||||
void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function<std::string(int)>& label);
|
void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function<std::string(int)>& label,
|
||||||
|
const std::function<std::string(int)>& value = nullptr);
|
||||||
|
|
||||||
// Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit.
|
// Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit.
|
||||||
void textLines(Canvas& c, const std::vector<std::string>& lines, int firstLine, theme::Rect area);
|
void textLines(Canvas& c, const std::vector<std::string>& lines, int firstLine, theme::Rect area);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "key_value_store.h"
|
||||||
|
|
||||||
|
// In-memory KeyValueStore for tests.
|
||||||
|
struct MemoryStore : roro::KeyValueStore {
|
||||||
|
std::map<std::string, int32_t> ints;
|
||||||
|
std::map<std::string, std::string> strings;
|
||||||
|
bool getInt(const char* key, int32_t& out) override {
|
||||||
|
auto it = ints.find(key);
|
||||||
|
if (it == ints.end()) return false;
|
||||||
|
out = it->second;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool getString(const char* key, std::string& out) override {
|
||||||
|
auto it = strings.find(key);
|
||||||
|
if (it == strings.end()) return false;
|
||||||
|
out = it->second;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void putInt(const char* key, int32_t v) override { ints[key] = v; }
|
||||||
|
void putString(const char* key, const std::string& v) override { strings[key] = v; }
|
||||||
|
};
|
||||||
@@ -155,6 +155,32 @@ void test_update_reaches_only_the_foreground_app() {
|
|||||||
TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate);
|
TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_modal_app_keeps_home_and_back() {
|
||||||
|
Fixture f;
|
||||||
|
f.manager.openModal("demo");
|
||||||
|
f.manager.handleKey(KeyEvent::of(Key::Home));
|
||||||
|
f.manager.handleKey(KeyEvent::of(Key::Back));
|
||||||
|
TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground());
|
||||||
|
TEST_ASSERT_EQUAL(2, f.demo.keys.size()); // both reached the App
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_ending_the_modal_returns_to_launcher_and_unlocks_home() {
|
||||||
|
Fixture f;
|
||||||
|
f.manager.openModal("demo");
|
||||||
|
f.manager.endModal();
|
||||||
|
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||||
|
f.manager.open("notes");
|
||||||
|
f.manager.handleKey(KeyEvent::of(Key::Home));
|
||||||
|
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_modal_app_cannot_be_switched_away_by_open() {
|
||||||
|
Fixture f;
|
||||||
|
f.manager.openModal("demo");
|
||||||
|
TEST_ASSERT_FALSE(f.manager.open("notes"));
|
||||||
|
TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground());
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
RUN_TEST(test_launcher_is_in_foreground_after_begin);
|
RUN_TEST(test_launcher_is_in_foreground_after_begin);
|
||||||
@@ -170,5 +196,8 @@ int main() {
|
|||||||
RUN_TEST(test_switching_apps_requests_a_redraw);
|
RUN_TEST(test_switching_apps_requests_a_redraw);
|
||||||
RUN_TEST(test_app_can_request_its_own_redraw);
|
RUN_TEST(test_app_can_request_its_own_redraw);
|
||||||
RUN_TEST(test_update_reaches_only_the_foreground_app);
|
RUN_TEST(test_update_reaches_only_the_foreground_app);
|
||||||
|
RUN_TEST(test_modal_app_keeps_home_and_back);
|
||||||
|
RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home);
|
||||||
|
RUN_TEST(test_modal_app_cannot_be_switched_away_by_open);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,29 +4,12 @@
|
|||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "../memory_store.h"
|
||||||
#include "event_bus.h"
|
#include "event_bus.h"
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
|
|
||||||
using namespace roro;
|
using namespace roro;
|
||||||
|
|
||||||
struct MemoryStore : KeyValueStore {
|
|
||||||
std::map<std::string, int32_t> ints;
|
|
||||||
std::map<std::string, std::string> strings;
|
|
||||||
bool getInt(const char* key, int32_t& out) override {
|
|
||||||
auto it = ints.find(key);
|
|
||||||
if (it == ints.end()) return false;
|
|
||||||
out = it->second;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
bool getString(const char* key, std::string& out) override {
|
|
||||||
auto it = strings.find(key);
|
|
||||||
if (it == strings.end()) return false;
|
|
||||||
out = it->second;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
void putInt(const char* key, int32_t v) override { ints[key] = v; }
|
|
||||||
void putString(const char* key, const std::string& v) override { strings[key] = v; }
|
|
||||||
};
|
|
||||||
|
|
||||||
void setUp() {}
|
void setUp() {}
|
||||||
void tearDown() {}
|
void tearDown() {}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include "../memory_store.h"
|
||||||
|
#include "event_bus.h"
|
||||||
|
#include "settings_menu.h"
|
||||||
|
|
||||||
|
using namespace roro;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
MemoryStore store;
|
||||||
|
EventBus bus;
|
||||||
|
Settings settings{store, bus};
|
||||||
|
SettingsMenu menu{settings};
|
||||||
|
Fixture() { settings.load(); }
|
||||||
|
int row(SettingsMenu::Row r) {
|
||||||
|
for (int i = 0; i < menu.count(); i++)
|
||||||
|
if (menu.row(i) == r) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
void test_every_row_has_a_label() {
|
||||||
|
Fixture f;
|
||||||
|
TEST_ASSERT_TRUE(f.menu.count() > 0);
|
||||||
|
for (int i = 0; i < f.menu.count(); i++) TEST_ASSERT_FALSE(f.menu.label(i).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_values_are_human_readable() {
|
||||||
|
Fixture f;
|
||||||
|
TEST_ASSERT_EQUAL_STRING("60%", f.menu.value(f.row(SettingsMenu::Row::Brightness)).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("30 s", f.menu.value(f.row(SettingsMenu::Row::DimTimeout)).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("1 min", f.menu.value(f.row(SettingsMenu::Row::OffTimeout)).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(f.row(SettingsMenu::Row::Sound)).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Brussels", f.menu.value(f.row(SettingsMenu::Row::Timezone)).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Raw", f.menu.value(f.row(SettingsMenu::Row::ProbeMacs)).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_toggle_rows_flip_on_select() {
|
||||||
|
Fixture f;
|
||||||
|
int sound = f.row(SettingsMenu::Row::Sound);
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Toggle), static_cast<int>(f.menu.kind(sound)));
|
||||||
|
f.menu.toggle(sound);
|
||||||
|
TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_choice_rows_list_options_and_mark_the_current_one() {
|
||||||
|
Fixture f;
|
||||||
|
int tz = f.row(SettingsMenu::Row::Timezone);
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Choice), static_cast<int>(f.menu.kind(tz)));
|
||||||
|
auto options = f.menu.choices(tz);
|
||||||
|
TEST_ASSERT_TRUE(options.size() >= 3);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Brussels", options[f.menu.currentChoice(tz)].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_choosing_applies_the_setting() {
|
||||||
|
Fixture f;
|
||||||
|
int tz = f.row(SettingsMenu::Row::Timezone);
|
||||||
|
auto options = f.menu.choices(tz);
|
||||||
|
int utc = -1;
|
||||||
|
for (size_t i = 0; i < options.size(); i++)
|
||||||
|
if (options[i] == "UTC") utc = static_cast<int>(i);
|
||||||
|
TEST_ASSERT_TRUE(f.menu.choose(tz, utc).empty());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("UTC0", f.settings.getString(Setting::Timezone).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_choice_that_breaks_a_rule_is_refused_with_a_reason() {
|
||||||
|
Fixture f;
|
||||||
|
int dim = f.row(SettingsMenu::Row::DimTimeout);
|
||||||
|
auto options = f.menu.choices(dim);
|
||||||
|
int fiveMinutes = -1;
|
||||||
|
for (size_t i = 0; i < options.size(); i++)
|
||||||
|
if (options[i] == "5 min") fiveMinutes = static_cast<int>(i);
|
||||||
|
TEST_ASSERT_TRUE(fiveMinutes >= 0);
|
||||||
|
std::string error = f.menu.choose(dim, fiveMinutes); // screen off is 1 min
|
||||||
|
TEST_ASSERT_FALSE(error.empty());
|
||||||
|
TEST_ASSERT_EQUAL(30, f.settings.getInt(Setting::DimTimeoutS));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_brightness_steps_with_left_right_within_bounds() {
|
||||||
|
Fixture f;
|
||||||
|
int b = f.row(SettingsMenu::Row::Brightness);
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Slider), static_cast<int>(f.menu.kind(b)));
|
||||||
|
f.menu.adjust(b, +1);
|
||||||
|
TEST_ASSERT_EQUAL(70, f.settings.getInt(Setting::Brightness));
|
||||||
|
for (int i = 0; i < 10; i++) f.menu.adjust(b, -1);
|
||||||
|
TEST_ASSERT_EQUAL(10, f.settings.getInt(Setting::Brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_names_are_text_rows_with_their_byte_limits() {
|
||||||
|
Fixture f;
|
||||||
|
int shortName = f.row(SettingsMenu::Row::ShortName);
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Text), static_cast<int>(f.menu.kind(shortName)));
|
||||||
|
TEST_ASSERT_EQUAL(4, f.menu.maxBytes(shortName));
|
||||||
|
TEST_ASSERT_FALSE(f.menu.setText(shortName, "TOOLONG").empty());
|
||||||
|
TEST_ASSERT_TRUE(f.menu.setText(shortName, "RORO").empty());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_every_row_has_a_label);
|
||||||
|
RUN_TEST(test_values_are_human_readable);
|
||||||
|
RUN_TEST(test_toggle_rows_flip_on_select);
|
||||||
|
RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one);
|
||||||
|
RUN_TEST(test_choosing_applies_the_setting);
|
||||||
|
RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason);
|
||||||
|
RUN_TEST(test_brightness_steps_with_left_right_within_bounds);
|
||||||
|
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include "../memory_store.h"
|
||||||
|
#include "event_bus.h"
|
||||||
|
#include "setup_wizard.h"
|
||||||
|
|
||||||
|
using namespace roro;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
MemoryStore store;
|
||||||
|
EventBus bus;
|
||||||
|
Settings settings{store, bus};
|
||||||
|
SetupWizard wizard{settings, "roro9stack 2fa4", "2fa4"};
|
||||||
|
Fixture() { settings.load(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
using Step = SetupWizard::Step;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
static void assertStep(Step expected, const SetupWizard& w) {
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(expected), static_cast<int>(w.step()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_walks_through_every_step_with_defaults() {
|
||||||
|
Fixture f;
|
||||||
|
assertStep(Step::Welcome, f.wizard);
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
assertStep(Step::LongName, f.wizard);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("roro9stack 2fa4", f.wizard.editor().text().c_str());
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
assertStep(Step::ShortName, f.wizard);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("2fa4", f.wizard.editor().text().c_str());
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
assertStep(Step::Region, f.wizard);
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
assertStep(Step::Timezone, f.wizard);
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
assertStep(Step::Done, f.wizard);
|
||||||
|
TEST_ASSERT_FALSE(f.wizard.finished());
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.next());
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.finished());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nothing_is_saved_until_the_end() {
|
||||||
|
Fixture f;
|
||||||
|
for (int i = 0; i < 5; i++) f.wizard.next();
|
||||||
|
TEST_ASSERT_FALSE(f.settings.getBool(Setting::SetupDone));
|
||||||
|
TEST_ASSERT_FALSE(f.settings.getBool(Setting::RegionConfirmed));
|
||||||
|
TEST_ASSERT_TRUE(f.store.ints.empty() && f.store.strings.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_finishing_saves_names_region_timezone_and_marks_setup_done() {
|
||||||
|
Fixture f;
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.editor().setText("Cl\xC3\xA9ment");
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.editor().setText("CLEM");
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.next(); // region
|
||||||
|
f.wizard.selectChoice(1); // second timezone
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.next();
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Cl\xC3\xA9ment", f.settings.getString(Setting::LongName).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("CLEM", f.settings.getString(Setting::ShortName).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("EU868", f.settings.getString(Setting::Region).c_str());
|
||||||
|
TEST_ASSERT_TRUE(f.settings.getBool(Setting::RegionConfirmed));
|
||||||
|
TEST_ASSERT_TRUE(f.settings.getBool(Setting::SetupDone));
|
||||||
|
TEST_ASSERT_TRUE(f.settings.getString(Setting::Timezone) != "CET-1CEST,M3.5.0,M10.5.0/3");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_empty_name_cannot_advance_and_explains_why() {
|
||||||
|
Fixture f;
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.editor().setText("");
|
||||||
|
TEST_ASSERT_FALSE(f.wizard.next());
|
||||||
|
assertStep(Step::LongName, f.wizard);
|
||||||
|
TEST_ASSERT_FALSE(f.wizard.error().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_short_name_editor_is_limited_to_four_bytes() {
|
||||||
|
Fixture f;
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.next();
|
||||||
|
TEST_ASSERT_EQUAL(4, f.wizard.editor().maxBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_back_returns_to_the_previous_step_keeping_input() {
|
||||||
|
Fixture f;
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.editor().setText("Alice");
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.back();
|
||||||
|
assertStep(Step::LongName, f.wizard);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Alice", f.wizard.editor().text().c_str());
|
||||||
|
f.wizard.back();
|
||||||
|
f.wizard.back();
|
||||||
|
assertStep(Step::Welcome, f.wizard);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_region_step_offers_eu868() {
|
||||||
|
Fixture f;
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.next();
|
||||||
|
f.wizard.next();
|
||||||
|
TEST_ASSERT_EQUAL(1, f.wizard.choiceCount());
|
||||||
|
TEST_ASSERT_TRUE(f.wizard.choiceLabel(0).find("EU868") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_walks_through_every_step_with_defaults);
|
||||||
|
RUN_TEST(test_nothing_is_saved_until_the_end);
|
||||||
|
RUN_TEST(test_finishing_saves_names_region_timezone_and_marks_setup_done);
|
||||||
|
RUN_TEST(test_empty_name_cannot_advance_and_explains_why);
|
||||||
|
RUN_TEST(test_short_name_editor_is_limited_to_four_bytes);
|
||||||
|
RUN_TEST(test_back_returns_to_the_previous_step_keeping_input);
|
||||||
|
RUN_TEST(test_region_step_offers_eu868);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user