M1 step 2: Wi-Fi Service, Saved Networks, NTP, Settings page

- lib/wifi (host-tested): SavedNetworks (up to 8, validated, hidden
  flag, persisted) and WifiController (joins the strongest Saved
  Network, tries hidden ones in turn, backoff 10/30/60 s, connect
  timeout, Monitoring override, radio off without Saved Networks)
- WifiService carries out the controller's actions, sets the EU
  country code, and syncs the Clock over SNTP without touching the TZ
- Wi-Fi On/Off setting; Settings > Wi-Fi page: status, add from a scan
  or a hidden network, forget
- Status Bar: W + signal bars, W? while searching, MON while monitoring
- Serial: wifi add / wifi status (replaces the step 1 heap probe)

Verified on the device: joins the test network ~5 s after boot, clock
set over NTP, ~129 KB free heap while connected.

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 14:46:07 +02:00
co-authored by Claude Opus 5.5
parent d8fe676c65
commit 1a0baf4ac7
22 changed files with 1108 additions and 48 deletions
+16 -2
View File
@@ -30,6 +30,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Choice: return onChoiceKey(e);
case Page::Storage: return onStorageKey(e);
case Page::About: return onAboutKey(e);
case Page::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
return true;
}
return false;
}
@@ -61,7 +64,16 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
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;
case Kind::Page:
switch (menu_.row(i)) {
case Row::Storage: page_ = Page::Storage; break;
case Row::Wifi:
page_ = Page::Wifi;
wifiPage_.enter();
break;
default: page_ = Page::About; break;
}
break;
}
return true;
}
@@ -123,7 +135,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
void SettingsApp::update(uint32_t nowMs) {
// Live values on Storage and About.
if ((page_ == Page::Storage || page_ == Page::About) && nowMs - lastRefreshMs_ >= 1000) {
bool live = page_ == Page::Storage || page_ == Page::About || (page_ == Page::Wifi && wifiPage_.live());
if (live && nowMs - lastRefreshMs_ >= 500) {
lastRefreshMs_ = nowMs;
requestRedraw();
}
@@ -204,6 +217,7 @@ void SettingsApp::draw(Canvas& c) {
*eraseDialog_);
break;
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break;
}
}
+10 -3
View File
@@ -13,6 +13,7 @@
#include "services/battery_service.h"
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "apps/wifi_settings_page.h"
#include "settings_menu.h"
#include "ui/theme.h"
@@ -25,20 +26,25 @@ struct SettingsAppDeps {
BatteryService& battery;
StorageService& storage;
ClockService& clock;
WifiService& wifi;
SavedNetworks& savedNetworks;
};
// 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) {}
explicit SettingsApp(const SettingsAppDeps& deps)
: d_(deps), menu_(deps.settings), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Text; }
bool textEntryActive() const override {
return page_ == Page::Text || (page_ == Page::Wifi && wifiPage_.textEntryActive());
}
void draw(Canvas& c) override;
private:
enum class Page { Menu, Text, Choice, Storage, About };
enum class Page { Menu, Text, Choice, Storage, About, Wifi };
bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e);
@@ -51,6 +57,7 @@ class SettingsApp : public App {
SettingsAppDeps d_;
SettingsMenu menu_;
WifiSettingsPage wifiPage_;
Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight};
ListModel choices_{theme::kContent.h / theme::kLineHeight};
+194
View File
@@ -0,0 +1,194 @@
#include "wifi_settings_page.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
namespace roro {
using State = WifiController::State;
void WifiSettingsPage::enter() {
view_ = View::Main;
forgetDialog_.reset();
refreshMain();
}
void WifiSettingsPage::refreshMain() { main_.setCount(kFixedRows + saved_.count()); }
void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
}
std::string WifiSettingsPage::statusText() const {
switch (wifi_.state()) {
case State::Off: return "Off";
case State::Scanning: return "Searching...";
case State::Waiting: return "No saved network in range";
case State::Connecting: return "Joining " + wifi_.ssid() + "...";
case State::Connected: return wifi_.ssid() + " (" + std::to_string(wifi_.rssi()) + " dBm)";
case State::Monitoring: return "Monitoring";
}
return "";
}
bool WifiSettingsPage::onKey(const KeyEvent& e) {
if (forgetDialog_) {
forgetDialog_->onKey(e);
if (forgetDialog_->result() == 1) {
saved_.forget(saved_.at(main_.selected() - kFixedRows).ssid);
refreshMain();
}
if (forgetDialog_->result() != DialogModel::kPending) forgetDialog_.reset();
return true;
}
switch (view_) {
case View::Main:
switch (e.key) {
case Key::Up: main_.up(); return true;
case Key::Down: main_.down(); return true;
case Key::Back: return false;
case Key::Left:
case Key::Right:
if (main_.selected() != kToggle) return true;
[[fallthrough]];
case Key::Select:
switch (main_.selected()) {
case kToggle: settings_.setBool(Setting::WifiEnabled, !settings_.getBool(Setting::WifiEnabled)); break;
case kStatus: break;
case kAddScanned:
wifi_.startListScan();
scan_.setCount(0);
view_ = View::Scan;
break;
case kAddHidden:
newHidden_ = true;
editor_ = LineEditor(32);
view_ = View::Ssid;
break;
default: forgetDialog_.reset(new DialogModel({"Cancel", "Forget"})); break;
}
return true;
default: return true;
}
case View::Scan: {
const auto& found = wifi_.listScan();
if (wifi_.listScanDone()) scan_.setCount(static_cast<int>(found.size()));
switch (e.key) {
case Key::Up: scan_.up(); break;
case Key::Down: scan_.down(); break;
case Key::Back: view_ = View::Main; break;
case Key::Select:
if (scan_.selected() < 0) break;
newSsid_ = found[scan_.selected()].ssid;
newHidden_ = false;
if (found[scan_.selected()].open) {
std::string error = saved_.add(newSsid_, "", false);
if (!error.empty()) warn(error);
wifi_.savedNetworksChanged();
enter();
} else {
editor_ = LineEditor(63);
view_ = View::Password;
}
break;
default: break;
}
return true;
}
case View::Ssid:
case View::Password:
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::Back: enter(); break;
case Key::Select:
if (view_ == View::Ssid) {
newSsid_ = editor_.text();
editor_ = LineEditor(63);
view_ = View::Password;
} else {
std::string error = saved_.add(newSsid_, editor_.text(), newHidden_);
if (!error.empty()) {
warn(error);
} else {
warn("Saved " + newSsid_, NotificationLevel::Info);
wifi_.savedNetworksChanged();
enter();
}
}
break;
default: break;
}
return true;
}
return true;
}
void WifiSettingsPage::draw(Canvas& c) {
const auto& area = theme::kContent;
switch (view_) {
case View::Main:
widgets::list(
c, main_, area,
[this](int i) -> std::string {
switch (i) {
case kToggle: return "Wi-Fi";
case kStatus: return "Status";
case kAddScanned: return "Add a network";
case kAddHidden: return "Add a hidden network";
default: {
const auto& n = saved_.at(i - kFixedRows);
return " " + n.ssid + (n.hidden ? " (hidden)" : "");
}
}
},
[this](int i) -> std::string {
switch (i) {
case kToggle: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
case kStatus: return statusText();
case kAddScanned:
case kAddHidden: return ">";
default: return "";
}
});
if (forgetDialog_ && main_.selected() >= kFixedRows)
widgets::dialog(c, "Forget network?", saved_.at(main_.selected() - kFixedRows).ssid, *forgetDialog_);
break;
case View::Scan: {
if (!wifi_.listScanDone()) {
widgets::textLines(c, {"Scanning..."}, 0, area);
break;
}
const auto& found = wifi_.listScan();
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
if (found.empty()) {
widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area);
break;
}
widgets::list(
c, scan_, area, [&](int i) { return found[i].ssid + (found[i].open ? " (open)" : ""); },
[&](int i) { return std::to_string(found[i].rssi) + " dBm"; });
break;
}
case View::Ssid:
case View::Password: {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_;
c.drawString(title.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(view_ == View::Ssid ? "Enter: next `: cancel" : "Enter: save (empty = open) `: cancel", 4,
area.y + 44);
break;
}
}
}
} // namespace roro
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <memory>
#include <string>
#include "dialog_model.h"
#include "event_bus.h"
#include "key_event.h"
#include "line_editor.h"
#include "list_model.h"
#include "saved_networks.h"
#include "services/wifi_service.h"
#include "settings.h"
#include "ui/canvas.h"
#include "ui/theme.h"
namespace roro {
// Settings → Wi-Fi: the On/Off switch, current connection, Saved Networks, and adding networks
// from a scan or by name (hidden). Owned by the Settings App.
class WifiSettingsPage {
public:
WifiSettingsPage(Settings& settings, SavedNetworks& saved, WifiService& wifi, EventBus& bus)
: settings_(settings), saved_(saved), wifi_(wifi), bus_(bus) {}
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password; }
bool live() const { return view_ == View::Main || view_ == View::Scan; } // redraw periodically
void draw(Canvas& c);
private:
enum class View { Main, Scan, Ssid, Password };
enum Fixed { kToggle, kStatus, kAddScanned, kAddHidden, kFixedRows };
void refreshMain();
void warn(const std::string& text, NotificationLevel level = NotificationLevel::Warning);
std::string statusText() const;
Settings& settings_;
SavedNetworks& saved_;
WifiService& wifi_;
EventBus& bus_;
View view_ = View::Main;
ListModel main_{theme::kContent.h / theme::kLineHeight};
ListModel scan_{theme::kContent.h / theme::kLineHeight};
LineEditor editor_{63};
std::string newSsid_;
bool newHidden_ = false;
std::unique_ptr<DialogModel> forgetDialog_;
};
} // namespace roro
+30 -40
View File
@@ -1,7 +1,5 @@
// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
#include <M5Cardputer.h>
#include <NetworkClientSecure.h>
#include <WiFi.h>
#include "app_manager.h"
#include "apps/demo_app.h"
@@ -16,6 +14,7 @@
#include "services/clock_service.h"
#include "services/power_service.h"
#include "services/storage_service.h"
#include "services/wifi_service.h"
#include "settings.h"
#include "ui/notifier.h"
#include "ui/screen.h"
@@ -35,6 +34,8 @@ static BatteryService* battery;
static StorageService* storage;
static PowerService* power;
static ClockService* clockService;
static SavedNetworks* savedNetworks;
static WifiService* wifi;
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -64,6 +65,18 @@ static StatusInfo currentStatus() {
s.sdPresent = storageState.present;
s.sdLevel = storageState.level;
s.compose = keyMapper.pendingCompose();
using WifiState = WifiController::State;
switch (wifi->state()) {
case WifiState::Connected: {
s.wifi = StatusInfo::Wifi::Connected;
int rssi = wifi->rssi();
s.wifiBars = rssi >= -60 ? 3 : rssi >= -70 ? 2 : rssi >= -80 ? 1 : 0;
break;
}
case WifiState::Monitoring: s.wifi = StatusInfo::Wifi::Monitoring; break;
case WifiState::Off: s.wifi = StatusInfo::Wifi::None; break;
default: s.wifi = StatusInfo::Wifi::Searching; break;
}
return s;
}
@@ -80,17 +93,21 @@ void setup() {
storage = new StorageService(bus);
power = new PowerService(settings);
clockService = new ClockService(settings, bus);
savedNetworks = new SavedNetworks(nvs);
savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService);
notifier = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
services.add(*power);
services.add(*clockService);
services.add(*battery);
services.add(*storage);
services.add(*wifi);
apps = new AppManager(launcher);
launcher.setManager(*apps);
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})});
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService, *wifi, *savedNetworks})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
@@ -103,41 +120,6 @@ void setup() {
Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap());
}
// Dev aid (M1 step 1): heap cost of Wi-Fi and one TLS connection. Credentials come over serial only.
static void memoryProbe(const String& ssid, const String& pass) {
auto heap = [](const char* stage) {
Serial.printf("probe %-18s heap %6u min %6u largest block %6u\n", stage, ESP.getFreeHeap(),
ESP.getMinFreeHeap(), heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
};
heap("before wifi");
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) delay(100);
if (WiFi.status() != WL_CONNECTED) {
Serial.printf("probe wifi failed, status %d\n", WiFi.status());
heap("wifi failed");
WiFi.disconnect(true);
return;
}
Serial.printf("probe wifi connected, rssi %d, ip %s\n", WiFi.RSSI(), WiFi.localIP().toString().c_str());
heap("wifi connected");
{
NetworkClientSecure tls;
tls.setInsecure(); // measurement only; M1 verifies certificates
if (tls.connect("irc.libera.chat", 6697)) {
heap("tls connected");
tls.stop();
} else {
Serial.println("probe tls connect failed");
}
}
heap("tls closed");
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
heap("wifi off");
}
// Dev aid: serial commands to drive the UI without the keyboard.
static void serialCommands() {
static String line;
@@ -157,10 +139,18 @@ static void serialCommands() {
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select;
if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key));
}
if (line.startsWith("probe ")) { // probe <ssid>\t<password>
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t');
if (tab > 0) memoryProbe(line.substring(6, tab), line.substring(tab + 1));
std::string ssid = (tab > 0 ? line.substring(9, tab) : line.substring(9)).c_str();
std::string pass = tab > 0 ? line.substring(tab + 1).c_str() : "";
std::string error = savedNetworks->add(ssid, pass);
Serial.printf("wifi add: %s\n", error.empty() ? "ok" : error.c_str());
wifi->savedNetworksChanged();
}
if (line == "wifi status")
Serial.printf("wifi: state %d ssid '%s' rssi %d clock %s heap %u min %u\n", (int)wifi->state(),
wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(),
ESP.getMinFreeHeap());
if (line == "sound off") settings.setBool(Setting::Sound, false);
if (line == "sound on") settings.setBool(Setting::Sound, true);
if (line == "short") {
+116
View File
@@ -0,0 +1,116 @@
#include "wifi_service.h"
#include <WiFi.h>
#include <esp_sntp.h>
#include <esp_wifi.h>
namespace roro {
int WifiService::rssi() const {
return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0;
}
void WifiService::startScan() {
if (!radioInitialised_) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false); // the controller decides when to reconnect
esp_wifi_set_country_code("EU", true);
radioInitialised_ = true;
}
if (scanRunning_) return;
if (WiFi.scanNetworks(true /* async */, false /* no hidden */) == WIFI_SCAN_FAILED) return;
scanRunning_ = true;
}
void WifiService::startListScan() {
listScanPending_ = true;
startScan();
}
void WifiService::finishScan(uint32_t nowMs) {
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) return;
scanRunning_ = false;
std::vector<ScanResult> results;
std::vector<ScanEntry> entries;
for (int i = 0; i < n; i++) {
std::string ssid = WiFi.SSID(i).c_str();
if (ssid.empty()) continue;
results.push_back({ssid, WiFi.RSSI(i)});
entries.push_back({ssid, WiFi.RSSI(i), WiFi.encryptionType(i) == WIFI_AUTH_OPEN});
}
WiFi.scanDelete();
if (listScanPending_) {
listScan_ = entries;
listScanPending_ = false;
}
apply(controller_.scanDone(results, nowMs));
}
void WifiService::startNtp() {
// Not configTime(): it would overwrite the POSIX TZ the Clock applies.
if (!esp_sntp_enabled()) {
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
} else {
esp_sntp_restart();
}
ntpWaiting_ = true;
}
void WifiService::apply(const WifiController::Step& step) {
using Action = WifiController::Action;
switch (step.action) {
case Action::None: break;
case Action::StartScan: startScan(); break;
case Action::Connect: WiFi.begin(step.ssid.c_str(), step.password.empty() ? nullptr : step.password.c_str()); break;
case Action::Disconnect: WiFi.disconnect(); break;
case Action::RadioOff:
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
radioInitialised_ = scanRunning_ = false;
break;
case Action::StartMonitor:
startScan(); // makes sure the radio is up
WiFi.disconnect();
break;
case Action::StopMonitorAndScan:
esp_wifi_set_promiscuous(false);
startScan();
break;
case Action::StopMonitorAndOff:
esp_wifi_set_promiscuous(false);
WiFi.mode(WIFI_OFF);
radioInitialised_ = scanRunning_ = false;
break;
}
}
void WifiService::tick(uint32_t nowMs) {
using State = WifiController::State;
if (scanRunning_) finishScan(nowMs);
wl_status_t status = WiFi.status();
if (controller_.state() == State::Connecting) {
if (status == WL_CONNECTED) {
apply(controller_.connected(nowMs));
startNtp();
} else if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
apply(controller_.disconnected(nowMs));
}
} else if (controller_.state() == State::Connected && status != WL_CONNECTED) {
apply(controller_.disconnected(nowMs));
}
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled)));
if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
ntpWaiting_ = false;
clock_.set(static_cast<int64_t>(time(nullptr)), TimeSource::Ntp);
}
}
} // namespace roro
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <vector>
#include "event_bus.h"
#include "saved_networks.h"
#include "service.h"
#include "services/clock_service.h"
#include "settings.h"
#include "wifi_controller.h"
namespace roro {
struct ScanEntry {
std::string ssid;
int rssi;
bool open;
};
// Owns the Wi-Fi radio: carries out the WifiController's decisions with the ESP32 Wi-Fi stack,
// syncs the clock over NTP once Connected, and runs scans for the Settings network list.
class WifiService : public Service {
public:
WifiService(Settings& settings, SavedNetworks& saved, ClockService& clock)
: settings_(settings), saved_(saved), clock_(clock), controller_(saved) {}
const char* name() const override { return "wifi"; }
uint32_t tickIntervalMs() const override { return 250; }
void tick(uint32_t nowMs) override;
WifiController::State state() const { return controller_.state(); }
const std::string& ssid() const { return controller_.ssid(); }
int rssi() const;
// Scan for the "add a network" list; results arrive in listScan() when listScanDone().
void startListScan();
bool listScanDone() const { return !listScanPending_; }
const std::vector<ScanEntry>& listScan() const { return listScan_; }
// A Saved Network was added: try it now rather than after the retry delay.
void savedNetworksChanged() { controller_.retryNow(millis()); }
void requestMonitor() { apply(controller_.requestMonitor()); }
void releaseMonitor() { apply(controller_.releaseMonitor(settings_.getBool(Setting::WifiEnabled))); }
private:
void apply(const WifiController::Step& step);
void startScan();
void finishScan(uint32_t nowMs);
void startNtp();
Settings& settings_;
SavedNetworks& saved_;
ClockService& clock_;
WifiController controller_;
bool scanRunning_ = false;
bool listScanPending_ = false;
std::vector<ScanEntry> listScan_;
bool ntpWaiting_ = false;
bool radioInitialised_ = false;
};
} // namespace roro
+15
View File
@@ -32,6 +32,21 @@ void statusBar(Canvas& c, const StatusInfo& info) {
if (info.batteryPercent >= 0)
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted);
switch (info.wifi) {
case StatusInfo::Wifi::Connected: {
// Three signal bars, then the label.
for (int i = 0; i < 3; i++) {
int h = 2 + 2 * i;
c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kMuted);
}
x -= 14;
right("W", kText);
break;
}
case StatusInfo::Wifi::Searching: right("W?", kMuted); break;
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
case StatusInfo::Wifi::None: break;
}
if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent);
c.setTextDatum(top_left);
}
+4 -1
View File
@@ -21,10 +21,13 @@ struct StatusInfo {
bool sdPresent = false;
int sdLevel = 0; // StorageMonitor level
char compose = 0; // pending Compose Key state
enum class Wifi { None, Searching, Connected, Monitoring } wifi = Wifi::None;
int wifiBars = 0; // 0..3 when Connected
bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose;
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
wifiBars == o.wifiBars;
}
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
};