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:
2026-10-02 13:47:03 +02:00
co-authored by Claude Opus 5.5
parent 05c9d9cc56
commit 51698a8011
23 changed files with 1126 additions and 154 deletions
+143
View File
@@ -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
+42
View File
@@ -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
+88
View File
@@ -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
+46
View File
@@ -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
+37 -9
View File
@@ -21,19 +21,41 @@ void AppManager::begin() {
redraw_ = true;
}
bool AppManager::open(const char* id) {
for (auto& info : apps_) {
if (std::strcmp(info.id, id) == 0) {
switchTo(*info.app);
return true;
}
}
return false;
App* AppManager::find(const char* id) const {
for (auto& info : apps_)
if (std::strcmp(info.id, id) == 0) return info.app;
return nullptr;
}
void AppManager::home() { switchTo(launcher_); }
bool AppManager::open(const char* id) {
App* app = find(id);
if (!app || modal_) return false;
switchTo(*app);
return true;
}
void AppManager::home() {
if (!modal_) 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) {
if (modal_) {
foreground_->onKey(event);
return;
}
if (event.key == Key::Home) {
home();
return;
@@ -42,6 +64,12 @@ void AppManager::handleKey(const KeyEvent& event) {
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 r = redraw_ | foreground_->consumeRedraw();
redraw_ = false;
+9 -1
View File
@@ -23,24 +23,32 @@ class AppManager {
std::vector<const AppInfo*> visibleApps() const;
void begin();
bool open(const char* id);
bool open(const char* id); // refused while a modal App runs
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 update(uint32_t nowMs) { foreground_->update(nowMs); }
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).
bool takeRedraw();
private:
void switchTo(App& app);
App* find(const char* id) const;
App& launcher_;
App* foreground_;
std::vector<AppInfo> apps_;
bool redraw_ = true;
bool modal_ = false;
};
} // namespace roro
+24
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ default_envs = cardputer-adv
[env]
extra_scripts = pre:scripts/version.py
test_framework = unity
lib_ldf_mode = deep+
[env:cardputer-adv]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
-84
View File
@@ -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
-33
View File
@@ -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
+210
View File
@@ -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
+62
View File
@@ -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
+99
View File
@@ -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
+24
View File
@@ -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
View File
@@ -3,8 +3,9 @@
#include "app_manager.h"
#include "apps/demo_app.h"
#include "apps/diagnostics_app.h"
#include "apps/launcher_app.h"
#include "apps/settings_app.h"
#include "apps/setup_app.h"
#include "event_bus.h"
#include "key_mapper.h"
#include "platform/nvs_store.h"
@@ -54,8 +55,7 @@ static RawKeys readKeys() {
static StatusInfo currentStatus() {
StatusInfo s;
s.title = kProductName;
for (auto* info : apps->visibleApps())
if (info->app == &apps->foreground()) s.title = info->title;
if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
s.clock = clockService->displayTime();
auto storageState = storage->state();
@@ -87,15 +87,17 @@ void setup() {
apps = new AppManager(launcher);
launcher.setManager(*apps);
apps->registerApp({"demo", "Widget demo", false, new DemoApp(bus)});
apps->registerApp({"diagnostics", "Diagnostics", false,
new DiagnosticsApp(settings, *battery, *storage, *clockService)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *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); });
if (!screen.begin()) Serial.println("frame buffer allocation failed");
services.startAll(millis());
apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap());
}
+32
View File
@@ -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
View File
@@ -36,7 +36,8 @@ void statusBar(Canvas& c, const StatusInfo& info) {
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.setTextDatum(top_left);
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);
c.setTextColor(kText);
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
if (model.count() > rows) {
+3 -2
View File
@@ -36,8 +36,9 @@ MeasureText bodyMeasure(Canvas& canvas);
void statusBar(Canvas& c, const StatusInfo& info);
// A selectable list; `label(i)` gives row i's text.
void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function<std::string(int)>& label);
// 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,
const std::function<std::string(int)>& value = nullptr);
// 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);
+26
View File
@@ -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);
}
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() {
UNITY_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_app_can_request_its_own_redraw);
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();
}
+1 -18
View File
@@ -4,29 +4,12 @@
#include <map>
#include <string>
#include "../memory_store.h"
#include "event_bus.h"
#include "settings.h"
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 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();
}