From d8fe676c657c871efe97a0ca67fbccddf05878dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 14:32:35 +0200 Subject: [PATCH 1/9] Add Wi-Fi/TLS heap probe; limit deep+ LDF to native tests The deep+ dependency finder broke the framework's WiFi -> Network include on the device build; only the native test env needs it. The probe serial command (probe TAB) measures heap with Wi-Fi connected and one TLS connection; credentials never touch the repo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- platformio.ini | 2 +- src/main.cpp | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index fbd0c01..955384d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -7,7 +7,6 @@ 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 @@ -25,4 +24,5 @@ test_ignore = * ; Host-side unit tests for pure logic (no hardware). [env:native] platform = native +lib_ldf_mode = deep+ build_flags = -std=gnu++17 diff --git a/src/main.cpp b/src/main.cpp index 0c67698..681c85c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,7 @@ // roro9stack firmware entry point: wires Services, Apps and the UI loop together. #include +#include +#include #include "app_manager.h" #include "apps/demo_app.h" @@ -101,6 +103,41 @@ 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; @@ -120,6 +157,10 @@ 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 \t + int tab = line.indexOf('\t'); + if (tab > 0) memoryProbe(line.substring(6, tab), line.substring(tab + 1)); + } if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "short") { From 1a0baf4ac7898a109d8c326f05e711a5771a2d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 14:46:07 +0200 Subject: [PATCH 2/9] 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 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 2 + lib/apps_model/src/settings_menu.cpp | 4 +- lib/apps_model/src/settings_menu.h | 2 +- lib/services/src/settings.cpp | 1 + lib/services/src/settings.h | 1 + lib/wifi/src/saved_networks.cpp | 67 ++++++ lib/wifi/src/saved_networks.h | 39 ++++ lib/wifi/src/wifi_controller.cpp | 99 +++++++++ lib/wifi/src/wifi_controller.h | 64 ++++++ src/apps/settings_app.cpp | 18 +- src/apps/settings_app.h | 13 +- src/apps/wifi_settings_page.cpp | 194 ++++++++++++++++ src/apps/wifi_settings_page.h | 53 +++++ src/main.cpp | 70 +++--- src/services/wifi_service.cpp | 116 ++++++++++ src/services/wifi_service.h | 62 ++++++ src/ui/widgets.cpp | 15 ++ src/ui/widgets.h | 5 +- .../test_saved_networks.cpp | 111 +++++++++ test/test_settings/test_settings.cpp | 1 + .../test_settings_menu/test_settings_menu.cpp | 9 + .../test_wifi_controller.cpp | 210 ++++++++++++++++++ 22 files changed, 1108 insertions(+), 48 deletions(-) create mode 100644 lib/wifi/src/saved_networks.cpp create mode 100644 lib/wifi/src/saved_networks.h create mode 100644 lib/wifi/src/wifi_controller.cpp create mode 100644 lib/wifi/src/wifi_controller.h create mode 100644 src/apps/wifi_settings_page.cpp create mode 100644 src/apps/wifi_settings_page.h create mode 100644 src/services/wifi_service.cpp create mode 100644 src/services/wifi_service.h create mode 100644 test/test_saved_networks/test_saved_networks.cpp create mode 100644 test/test_wifi_controller/test_wifi_controller.cpp diff --git a/README.md b/README.md index ff012fb..5383907 100644 --- a/README.md +++ b/README.md @@ -47,5 +47,7 @@ sudo usermod -aG dialout "$USER" | `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | +| `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | +| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | `scripts/flash.sh` stops a running serial log first, since it would hold the port. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index 3059ebe..e281ddc 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -20,7 +20,8 @@ const RowDef kRows[] = { {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::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, + {Row::Storage, Kind::Page, "Storage"}, {Row::About, Kind::Page, "About"}, }; @@ -78,6 +79,7 @@ std::string SettingsMenu::value(int i) const { 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"; + case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; default: return ""; } } diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 9f65cd7..3dbd517 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // 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 Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index 9de2e93..ca8cb69 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -28,6 +28,7 @@ const Definition kDefinitions[] = { {"off_s", Kind::Int, 60, nullptr, 10, 3600}, {"sound", Kind::Bool, 1, nullptr, 0, 1}, {"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1}, + {"wifi_on", Kind::Bool, 1, nullptr, 0, 1}, }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index d709cb8..8b48102 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -20,6 +20,7 @@ enum class Setting : uint8_t { OffTimeoutS, // int, 10..3600, above DimTimeoutS Sound, // bool ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses + WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range Count }; diff --git a/lib/wifi/src/saved_networks.cpp b/lib/wifi/src/saved_networks.cpp new file mode 100644 index 0000000..27f9134 --- /dev/null +++ b/lib/wifi/src/saved_networks.cpp @@ -0,0 +1,67 @@ +#include "saved_networks.h" + +namespace roro { + +namespace { +std::string key(int i, const char* field) { return "net" + std::to_string(i) + "_" + field; } +} // namespace + +void SavedNetworks::load() { + networks_.clear(); + int32_t n = 0; + store_.getInt("net_count", n); + for (int i = 0; i < n && i < kMax; i++) { + SavedNetwork net; + if (!store_.getString(key(i, "ssid").c_str(), net.ssid)) break; + store_.getString(key(i, "pass").c_str(), net.password); + int32_t hidden = 0; + store_.getInt(key(i, "hid").c_str(), hidden); + net.hidden = hidden != 0; + networks_.push_back(net); + } +} + +const SavedNetwork* SavedNetworks::find(const std::string& ssid) const { + for (auto& n : networks_) + if (n.ssid == ssid) return &n; + return nullptr; +} + +std::string SavedNetworks::add(const std::string& ssid, const std::string& password, bool hidden) { + if (ssid.empty() || ssid.size() > 32) return "Network name must be 1 to 32 bytes"; + if (!password.empty() && (password.size() < 8 || password.size() > 63)) + return "Password must be 8 to 63 characters (or empty for an open network)"; + for (auto& n : networks_) { + if (n.ssid == ssid) { + n.password = password; + n.hidden = hidden; + save(); + return ""; + } + } + if (count() >= kMax) return "Already 8 saved networks: forget one first"; + networks_.push_back({ssid, password, hidden}); + save(); + return ""; +} + +void SavedNetworks::forget(const std::string& ssid) { + for (auto it = networks_.begin(); it != networks_.end(); ++it) { + if (it->ssid == ssid) { + networks_.erase(it); + save(); + return; + } + } +} + +void SavedNetworks::save() { + for (int i = 0; i < count(); i++) { + store_.putString(key(i, "ssid").c_str(), networks_[i].ssid); + store_.putString(key(i, "pass").c_str(), networks_[i].password); + store_.putInt(key(i, "hid").c_str(), networks_[i].hidden ? 1 : 0); + } + store_.putInt("net_count", count()); +} + +} // namespace roro diff --git a/lib/wifi/src/saved_networks.h b/lib/wifi/src/saved_networks.h new file mode 100644 index 0000000..9f04f59 --- /dev/null +++ b/lib/wifi/src/saved_networks.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "key_value_store.h" + +namespace roro { + +struct SavedNetwork { + std::string ssid; + std::string password; // empty for an open network + bool hidden = false; // doesn't broadcast its name, so never shows up in scans +}; + +// The Saved Networks the Wi-Fi Service may join (see CONTEXT.md), persisted in internal flash. +class SavedNetworks { + public: + static constexpr int kMax = 8; + + explicit SavedNetworks(KeyValueStore& store) : store_(store) {} + + void load(); + int count() const { return static_cast(networks_.size()); } + const SavedNetwork& at(int i) const { return networks_[i]; } + const SavedNetwork* find(const std::string& ssid) const; + + // Adds, or updates the password of an existing SSID. Empty on success, otherwise why not. + std::string add(const std::string& ssid, const std::string& password, bool hidden = false); + void forget(const std::string& ssid); + + private: + void save(); + + KeyValueStore& store_; + std::vector networks_; +}; + +} // namespace roro diff --git a/lib/wifi/src/wifi_controller.cpp b/lib/wifi/src/wifi_controller.cpp new file mode 100644 index 0000000..ba9d854 --- /dev/null +++ b/lib/wifi/src/wifi_controller.cpp @@ -0,0 +1,99 @@ +#include "wifi_controller.h" + +namespace roro { + +namespace { +const uint32_t kRetryDelaysMs[] = {10000, 30000, 60000}; +const int kRetrySteps = sizeof(kRetryDelaysMs) / sizeof(kRetryDelaysMs[0]); +} // namespace + +WifiController::Step WifiController::wait(uint32_t nowMs) { + state_ = State::Waiting; + retryAtMs_ = nowMs + kRetryDelaysMs[retries_ < kRetrySteps ? retries_ : kRetrySteps - 1]; + retries_++; + return act(Action::None); +} + +WifiController::Step WifiController::update(uint32_t nowMs, bool enabled) { + if (state_ == State::Monitoring) return act(Action::None); + if (!enabled || saved_.count() == 0) { // nothing to join: keep the radio off + if (state_ == State::Off) return act(Action::None); + state_ = State::Off; + ssid_.clear(); + return act(Action::RadioOff); + } + switch (state_) { + case State::Off: + state_ = State::Scanning; + return act(Action::StartScan); + case State::Waiting: + if (static_cast(nowMs - retryAtMs_) < 0) return act(Action::None); + state_ = State::Scanning; + return act(Action::StartScan); + case State::Connecting: + if (nowMs - sinceMs_ < kConnectTimeoutMs) return act(Action::None); + wait(nowMs); + return act(Action::Disconnect); + default: + return act(Action::None); + } +} + +WifiController::Step WifiController::scanDone(const std::vector& results, uint32_t nowMs) { + if (state_ != State::Scanning) return act(Action::None); + const ScanResult* best = nullptr; + for (auto& r : results) + if (saved_.find(r.ssid) && (!best || r.rssi > best->rssi)) best = &r; + const SavedNetwork* net = best ? saved_.find(best->ssid) : nullptr; + if (!net) { + // Hidden networks never appear in scans: try them one at a time. + for (int i = 0; i < saved_.count() && !net; i++) { + const SavedNetwork& candidate = saved_.at((nextHidden_ + i) % saved_.count()); + if (candidate.hidden) { + net = &candidate; + nextHidden_ = (nextHidden_ + i + 1) % saved_.count(); + } + } + } + if (!net) return wait(nowMs); + + state_ = State::Connecting; + ssid_ = net->ssid; + sinceMs_ = nowMs; + return Step{Action::Connect, net->ssid, net->password}; +} + +WifiController::Step WifiController::connected(uint32_t) { + if (state_ != State::Connecting) return act(Action::None); + state_ = State::Connected; + retries_ = 0; + return act(Action::None); +} + +WifiController::Step WifiController::disconnected(uint32_t nowMs) { + if (state_ == State::Connected) { + state_ = State::Scanning; + return act(Action::StartScan); + } + if (state_ == State::Connecting) return wait(nowMs); // e.g. wrong password + return act(Action::None); +} + +WifiController::Step WifiController::requestMonitor() { + state_ = State::Monitoring; + ssid_.clear(); + return act(Action::StartMonitor); +} + +WifiController::Step WifiController::releaseMonitor(bool enabled) { + if (state_ != State::Monitoring) return act(Action::None); + retries_ = 0; + if (enabled) { + state_ = State::Scanning; + return act(Action::StopMonitorAndScan); + } + state_ = State::Off; + return act(Action::StopMonitorAndOff); +} + +} // namespace roro diff --git a/lib/wifi/src/wifi_controller.h b/lib/wifi/src/wifi_controller.h new file mode 100644 index 0000000..4534507 --- /dev/null +++ b/lib/wifi/src/wifi_controller.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#include "saved_networks.h" + +namespace roro { + +struct ScanResult { + std::string ssid; + int rssi; +}; + +// Decides what the Wi-Fi radio should do next. The Wi-Fi Service feeds it what happened (scan +// results, connected, disconnected, Monitoring requests) and carries out the returned Action. +// Stays Connected to the strongest Saved Network in range while enabled (radio off when there are +// no Saved Networks); Monitoring overrides. +class WifiController { + public: + enum class State { Off, Scanning, Connecting, Connected, Waiting, Monitoring }; + enum class Action { None, StartScan, Connect, Disconnect, RadioOff, StartMonitor, StopMonitorAndScan, StopMonitorAndOff }; + + struct Step { + Action action = Action::None; + std::string ssid; + std::string password; + }; + + static constexpr uint32_t kConnectTimeoutMs = 15000; + + explicit WifiController(const SavedNetworks& saved) : saved_(saved) {} + + Step update(uint32_t nowMs, bool enabled); + Step scanDone(const std::vector& results, uint32_t nowMs); + Step connected(uint32_t nowMs); + Step disconnected(uint32_t nowMs); + // A Saved Network was just added: stop waiting and scan now. + void retryNow(uint32_t nowMs) { + if (state_ == State::Waiting) retryAtMs_ = nowMs; + retries_ = 0; + } + + Step requestMonitor(); + Step releaseMonitor(bool enabled); + + State state() const { return state_; } + const std::string& ssid() const { return ssid_; } // network joined or being joined + + private: + Step wait(uint32_t nowMs); + static Step act(Action a) { return Step{a, "", ""}; } + + const SavedNetworks& saved_; + State state_ = State::Off; + std::string ssid_; + uint32_t sinceMs_ = 0; // when Connecting started + uint32_t retryAtMs_ = 0; // when Waiting ends + int retries_ = 0; + int nextHidden_ = 0; // round-robin over hidden Saved Networks +}; + +} // namespace roro diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 73efcd7..7ee76b8 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -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; } } diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index 4332d18..8ad6e08 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -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}; diff --git a/src/apps/wifi_settings_page.cpp b/src/apps/wifi_settings_page.cpp new file mode 100644 index 0000000..ec626d8 --- /dev/null +++ b/src/apps/wifi_settings_page.cpp @@ -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(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(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(found.size())) scan_.setCount(static_cast(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 diff --git a/src/apps/wifi_settings_page.h b/src/apps/wifi_settings_page.h new file mode 100644 index 0000000..6984fa1 --- /dev/null +++ b/src/apps/wifi_settings_page.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#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 forgetDialog_; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 681c85c..bc06c5b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,5 @@ // roro9stack firmware entry point: wires Services, Apps and the UI loop together. #include -#include -#include #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 \t + if (line.startsWith("wifi add ")) { // wifi add \t: 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") { diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp new file mode 100644 index 0000000..b1b088b --- /dev/null +++ b/src/services/wifi_service.cpp @@ -0,0 +1,116 @@ +#include "wifi_service.h" + +#include +#include +#include + +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 results; + std::vector 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(time(nullptr)), TimeSource::Ntp); + } +} + +} // namespace roro diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h new file mode 100644 index 0000000..a5d92bb --- /dev/null +++ b/src/services/wifi_service.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +#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& 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 listScan_; + bool ntpWaiting_ = false; + bool radioInitialised_ = false; +}; + +} // namespace roro diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index 6d5c631..ba8bccc 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -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); } diff --git a/src/ui/widgets.h b/src/ui/widgets.h index 93894da..c0c3fbf 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -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); } }; diff --git a/test/test_saved_networks/test_saved_networks.cpp b/test/test_saved_networks/test_saved_networks.cpp new file mode 100644 index 0000000..648d5d6 --- /dev/null +++ b/test/test_saved_networks/test_saved_networks.cpp @@ -0,0 +1,111 @@ +#include + +#include "../memory_store.h" +#include "saved_networks.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_empty_store_has_no_networks() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + TEST_ASSERT_EQUAL(0, n.count()); +} + +void test_added_networks_survive_reload() { + MemoryStore store; + { + SavedNetworks n(store); + n.load(); + TEST_ASSERT_TRUE(n.add("home", "password1").empty()); + TEST_ASSERT_TRUE(n.add("cafe", "").empty()); // open network + } + SavedNetworks again(store); + again.load(); + TEST_ASSERT_EQUAL(2, again.count()); + TEST_ASSERT_EQUAL_STRING("home", again.at(0).ssid.c_str()); + TEST_ASSERT_EQUAL_STRING("password1", again.at(0).password.c_str()); + TEST_ASSERT_EQUAL_STRING("", again.at(1).password.c_str()); +} + +void test_adding_an_existing_ssid_updates_its_password() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + n.add("home", "password1"); + n.add("home", "password2"); + TEST_ASSERT_EQUAL(1, n.count()); + TEST_ASSERT_EQUAL_STRING("password2", n.at(0).password.c_str()); +} + +void test_at_most_eight_networks() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + for (int i = 0; i < 8; i++) TEST_ASSERT_TRUE(n.add("net" + std::to_string(i), "").empty()); + TEST_ASSERT_FALSE(n.add("ninth", "").empty()); + TEST_ASSERT_EQUAL(8, n.count()); +} + +void test_forget_removes_and_persists() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + n.add("a", ""); + n.add("b", ""); + n.add("c", ""); + n.forget("b"); + SavedNetworks again(store); + again.load(); + TEST_ASSERT_EQUAL(2, again.count()); + TEST_ASSERT_EQUAL_STRING("a", again.at(0).ssid.c_str()); + TEST_ASSERT_EQUAL_STRING("c", again.at(1).ssid.c_str()); +} + +void test_validation_follows_wifi_limits() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + TEST_ASSERT_FALSE(n.add("", "").empty()); // SSID 1..32 bytes + TEST_ASSERT_FALSE(n.add(std::string(33, 's'), "").empty()); + TEST_ASSERT_FALSE(n.add("home", "short").empty()); // WPA: 8..63, or empty for open + TEST_ASSERT_FALSE(n.add("home", std::string(64, 'p')).empty()); + TEST_ASSERT_TRUE(n.add(std::string(32, 's'), std::string(63, 'p')).empty()); +} + +void test_hidden_flag_survives_reload() { + MemoryStore store; + { + SavedNetworks n(store); + n.load(); + n.add("secret", "password1", true); + } + SavedNetworks again(store); + again.load(); + TEST_ASSERT_TRUE(again.at(0).hidden); +} + +void test_find() { + MemoryStore store; + SavedNetworks n(store); + n.load(); + n.add("home", "password1"); + TEST_ASSERT_NOT_NULL(n.find("home")); + TEST_ASSERT_NULL(n.find("other")); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_empty_store_has_no_networks); + RUN_TEST(test_added_networks_survive_reload); + RUN_TEST(test_adding_an_existing_ssid_updates_its_password); + RUN_TEST(test_at_most_eight_networks); + RUN_TEST(test_forget_removes_and_persists); + RUN_TEST(test_validation_follows_wifi_limits); + RUN_TEST(test_hidden_flag_survives_reload); + RUN_TEST(test_find); + return UNITY_END(); +} diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp index 41b5bca..ac6c7e2 100644 --- a/test/test_settings/test_settings.cpp +++ b/test/test_settings/test_settings.cpp @@ -25,6 +25,7 @@ void test_defaults_when_store_is_empty() { TEST_ASSERT_EQUAL(60, s.getInt(Setting::OffTimeoutS)); TEST_ASSERT_TRUE(s.getBool(Setting::Sound)); TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw)); + TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled)); TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); } diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index 3bc0326..0a76767 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -89,6 +89,14 @@ void test_brightness_steps_with_left_right_within_bounds() { TEST_ASSERT_EQUAL(10, f.settings.getInt(Setting::Brightness)); } +void test_wifi_is_a_page() { + Fixture f; + int wifi = f.row(SettingsMenu::Row::Wifi); + TEST_ASSERT_TRUE(wifi >= 0); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Page), static_cast(f.menu.kind(wifi))); + TEST_ASSERT_EQUAL_STRING("On", f.menu.value(wifi).c_str()); +} + void test_names_are_text_rows_with_their_byte_limits() { Fixture f; int shortName = f.row(SettingsMenu::Row::ShortName); @@ -108,6 +116,7 @@ int main() { 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_wifi_is_a_page); RUN_TEST(test_names_are_text_rows_with_their_byte_limits); return UNITY_END(); } diff --git a/test/test_wifi_controller/test_wifi_controller.cpp b/test/test_wifi_controller/test_wifi_controller.cpp new file mode 100644 index 0000000..bfd8b8a --- /dev/null +++ b/test/test_wifi_controller/test_wifi_controller.cpp @@ -0,0 +1,210 @@ +#include + +#include "../memory_store.h" +#include "wifi_controller.h" + +using namespace roro; +using State = WifiController::State; +using Action = WifiController::Action; + +struct Fixture { + MemoryStore store; + SavedNetworks saved{store}; + WifiController wifi{saved}; + Fixture() { + saved.load(); + saved.add("home", "password1"); + saved.add("work", "password2"); + } +}; + +void setUp() {} +void tearDown() {} + +static void assertState(State s, const WifiController& w) { + TEST_ASSERT_EQUAL(static_cast(s), static_cast(w.state())); +} +static void assertAction(Action expected, const WifiController::Step& step) { + TEST_ASSERT_EQUAL(static_cast(expected), static_cast(step.action)); +} + +void test_disabled_wifi_stays_off() { + Fixture f; + assertAction(Action::None, f.wifi.update(0, false)); + assertState(State::Off, f.wifi); +} + +void test_enabling_starts_a_scan() { + Fixture f; + assertAction(Action::StartScan, f.wifi.update(0, true)); + assertState(State::Scanning, f.wifi); +} + +void test_scan_joins_the_strongest_saved_network() { + Fixture f; + f.wifi.update(0, true); + auto step = f.wifi.scanDone({{"cafe", -30}, {"home", -70}, {"work", -55}, {"home", -50}}, 100); + assertAction(Action::Connect, step); + TEST_ASSERT_EQUAL_STRING("home", step.ssid.c_str()); // -50 beats work's -55 + TEST_ASSERT_EQUAL_STRING("password1", step.password.c_str()); + assertState(State::Connecting, f.wifi); +} + +void test_no_saved_network_in_range_waits_then_rescans_with_growing_delay() { + Fixture f; + f.wifi.update(0, true); + assertAction(Action::None, f.wifi.scanDone({{"cafe", -30}}, 1000)); + assertState(State::Waiting, f.wifi); + assertAction(Action::None, f.wifi.update(10999, true)); + assertAction(Action::StartScan, f.wifi.update(11000, true)); // 10 s + f.wifi.scanDone({}, 12000); + assertAction(Action::None, f.wifi.update(41999, true)); + assertAction(Action::StartScan, f.wifi.update(42000, true)); // then 30 s +} + +void test_connected_resets_the_retry_delay() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({}, 0); + f.wifi.update(10000, true); + f.wifi.scanDone({{"home", -50}}, 10000); + f.wifi.connected(11000); + assertState(State::Connected, f.wifi); + TEST_ASSERT_EQUAL_STRING("home", f.wifi.ssid().c_str()); + f.wifi.disconnected(20000); + assertState(State::Scanning, f.wifi); +} + +void test_losing_the_connection_rescans_immediately() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({{"home", -50}}, 0); + f.wifi.connected(100); + auto step = f.wifi.disconnected(5000); + assertAction(Action::StartScan, step); +} + +void test_connect_timeout_counts_as_failure() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({{"home", -50}}, 0); + assertAction(Action::None, f.wifi.update(14999, true)); + assertAction(Action::Disconnect, f.wifi.update(15000, true)); + assertState(State::Waiting, f.wifi); +} + +void test_disabling_disconnects_and_turns_off() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({{"home", -50}}, 0); + f.wifi.connected(100); + assertAction(Action::RadioOff, f.wifi.update(200, false)); + assertState(State::Off, f.wifi); +} + +void test_monitoring_takes_over_and_reconnects_afterwards() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({{"home", -50}}, 0); + f.wifi.connected(100); + assertAction(Action::StartMonitor, f.wifi.requestMonitor()); + assertState(State::Monitoring, f.wifi); + assertAction(Action::None, f.wifi.update(60000, true)); // stays monitoring + assertAction(Action::StopMonitorAndScan, f.wifi.releaseMonitor(true)); + assertState(State::Scanning, f.wifi); +} + +void test_monitoring_works_even_when_wifi_is_disabled() { + Fixture f; + f.wifi.update(0, false); + assertAction(Action::StartMonitor, f.wifi.requestMonitor()); + assertAction(Action::StopMonitorAndOff, f.wifi.releaseMonitor(false)); + assertState(State::Off, f.wifi); +} + +void test_late_scan_or_connect_results_are_ignored_while_monitoring() { + Fixture f; + f.wifi.update(0, true); + f.wifi.requestMonitor(); + assertAction(Action::None, f.wifi.scanDone({{"home", -50}}, 10)); + f.wifi.connected(20); + assertState(State::Monitoring, f.wifi); +} + +void test_hidden_networks_are_tried_when_no_visible_one_is_in_range() { + MemoryStore store; + SavedNetworks saved(store); + saved.load(); + saved.add("home", "password1"); + saved.add("hideA", "password2", true); + saved.add("hideB", "password3", true); + WifiController wifi(saved); + wifi.update(0, true); + auto step = wifi.scanDone({{"cafe", -30}}, 0); + assertAction(Action::Connect, step); + TEST_ASSERT_EQUAL_STRING("hideA", step.ssid.c_str()); + wifi.disconnected(100); // failed + wifi.update(10100, true); + TEST_ASSERT_EQUAL_STRING("hideB", wifi.scanDone({}, 10100).ssid.c_str()); // the next one in turn +} + +void test_visible_saved_network_beats_hidden_ones() { + MemoryStore store; + SavedNetworks saved(store); + saved.load(); + saved.add("hideA", "password2", true); + saved.add("home", "password1"); + WifiController wifi(saved); + wifi.update(0, true); + TEST_ASSERT_EQUAL_STRING("home", wifi.scanDone({{"home", -80}}, 0).ssid.c_str()); +} + +void test_retry_now_cuts_the_wait_short() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({}, 0); + f.wifi.retryNow(500); + assertAction(Action::StartScan, f.wifi.update(500, true)); +} + +void test_no_saved_networks_keeps_the_radio_off() { + MemoryStore store; + SavedNetworks saved(store); + saved.load(); + WifiController wifi(saved); + assertAction(Action::None, wifi.update(0, true)); + assertState(State::Off, wifi); + saved.add("home", "password1"); + assertAction(Action::StartScan, wifi.update(100, true)); +} + +void test_forgetting_the_last_network_turns_the_radio_off() { + Fixture f; + f.wifi.update(0, true); + f.wifi.scanDone({{"home", -50}}, 0); + f.wifi.connected(100); + f.saved.forget("home"); + f.saved.forget("work"); + assertAction(Action::RadioOff, f.wifi.update(200, true)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_disabled_wifi_stays_off); + RUN_TEST(test_enabling_starts_a_scan); + RUN_TEST(test_scan_joins_the_strongest_saved_network); + RUN_TEST(test_no_saved_network_in_range_waits_then_rescans_with_growing_delay); + RUN_TEST(test_connected_resets_the_retry_delay); + RUN_TEST(test_losing_the_connection_rescans_immediately); + RUN_TEST(test_connect_timeout_counts_as_failure); + RUN_TEST(test_disabling_disconnects_and_turns_off); + RUN_TEST(test_monitoring_takes_over_and_reconnects_afterwards); + RUN_TEST(test_monitoring_works_even_when_wifi_is_disabled); + RUN_TEST(test_late_scan_or_connect_results_are_ignored_while_monitoring); + RUN_TEST(test_hidden_networks_are_tried_when_no_visible_one_is_in_range); + RUN_TEST(test_visible_saved_network_beats_hidden_ones); + RUN_TEST(test_retry_now_cuts_the_wait_short); + RUN_TEST(test_no_saved_networks_keeps_the_radio_off); + RUN_TEST(test_forgetting_the_last_network_turns_the_radio_off); + return UNITY_END(); +} From d3948ccc3a17b9f22c2f96644f5b406e736fe6d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 14:56:06 +0200 Subject: [PATCH 3/9] M1 step 3: Log writing and Storage Clean-up - lib/storage_model (host-tested): FAT-safe names, daily Log paths, dates from Log/Capture file names, CleanupPlan by category and age, byte formatting - StorageService does all card I/O on its task: queued Log lines are written in batches each second (dropped while Logs are paused), plus file listing and deletion jobs - Settings > Storage moves into StoragePage: usage, Clean up (category, age with size preview, confirmation), Erase SD card - Clock: local date for Log names - Serial: log , sd list Verified on the device: lines land in /irc/dev/#test/2026-10-02.log with folders created as needed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 2 + lib/services/src/clock_model.cpp | 9 + lib/services/src/clock_model.h | 1 + lib/storage_model/src/cleanup_plan.cpp | 53 ++++++ lib/storage_model/src/cleanup_plan.h | 40 ++++ lib/storage_model/src/storage_paths.cpp | 67 +++++++ lib/storage_model/src/storage_paths.h | 22 +++ src/apps/settings_app.cpp | 51 +----- src/apps/settings_app.h | 10 +- src/apps/storage_page.cpp | 171 ++++++++++++++++++ src/apps/storage_page.h | 49 +++++ src/main.cpp | 36 +++- src/services/clock_service.h | 5 + src/services/storage_service.cpp | 159 ++++++++++++++-- src/services/storage_service.h | 49 ++++- test/test_cleanup_plan/test_cleanup_plan.cpp | 74 ++++++++ test/test_clock/test_clock.cpp | 7 + .../test_storage_paths/test_storage_paths.cpp | 54 ++++++ 18 files changed, 785 insertions(+), 74 deletions(-) create mode 100644 lib/storage_model/src/cleanup_plan.cpp create mode 100644 lib/storage_model/src/cleanup_plan.h create mode 100644 lib/storage_model/src/storage_paths.cpp create mode 100644 lib/storage_model/src/storage_paths.h create mode 100644 src/apps/storage_page.cpp create mode 100644 src/apps/storage_page.h create mode 100644 test/test_cleanup_plan/test_cleanup_plan.cpp create mode 100644 test/test_storage_paths/test_storage_paths.cpp diff --git a/README.md b/README.md index 5383907..580ac2a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ sudo usermod -aG dialout "$USER" | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | +| `log ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | +| `sd list` | Lists the files of each Storage Clean-up category | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | `scripts/flash.sh` stops a running serial log first, since it would hold the port. diff --git a/lib/services/src/clock_model.cpp b/lib/services/src/clock_model.cpp index 4ef4544..61d315a 100644 --- a/lib/services/src/clock_model.cpp +++ b/lib/services/src/clock_model.cpp @@ -34,6 +34,15 @@ std::string ClockModel::formatLocalTime(int64_t utcSeconds) { return buf; } +std::string ClockModel::formatLocalDate(int64_t utcSeconds) { + time_t t = static_cast(utcSeconds); + struct tm local; + localtime_r(&t, &local); + char buf[12]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday); + return buf; +} + std::string ClockModel::formatAge(int64_t seconds) { char buf[24]; if (seconds < 60) return "now"; diff --git a/lib/services/src/clock_model.h b/lib/services/src/clock_model.h index adb4cbb..cb3abfe 100644 --- a/lib/services/src/clock_model.h +++ b/lib/services/src/clock_model.h @@ -21,6 +21,7 @@ class ClockModel { static void applyTimezone(const char* posixTz); static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM" + static std::string formatLocalDate(int64_t utcSeconds); // "YYYY-MM-DD" static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago" private: diff --git a/lib/storage_model/src/cleanup_plan.cpp b/lib/storage_model/src/cleanup_plan.cpp new file mode 100644 index 0000000..045fe98 --- /dev/null +++ b/lib/storage_model/src/cleanup_plan.cpp @@ -0,0 +1,53 @@ +#include "cleanup_plan.h" + +#include + +#include "storage_paths.h" + +namespace roro { + +namespace { +int maxAgeDays(CleanupAge age) { + switch (age) { + case CleanupAge::OneMonth: return 30; + case CleanupAge::ThreeMonths: return 91; + case CleanupAge::SixMonths: return 182; + case CleanupAge::OneYear: return 365; + default: return -1; + } +} +} // namespace + +CleanupPlan CleanupPlan::make(const std::vector& files, CleanupAge age, int todayDay) { + CleanupPlan plan; + for (auto& f : files) { + if (f.path.rfind("/notes", 0) == 0) continue; // Notes are never cleaned up + bool selected = age == CleanupAge::Everything; + if (!selected) { + size_t slash = f.path.find_last_of('/'); + int day = storage::fileDay(slash == std::string::npos ? f.path : f.path.substr(slash + 1)); + selected = day >= 0 && todayDay - day > maxAgeDays(age); + } + if (selected) { + plan.paths.push_back(f.path); + plan.bytes += f.bytes; + } + } + return plan; +} + +std::string formatBytes(uint64_t bytes) { + const char* units[] = {"B", "KB", "MB", "GB"}; + double v = static_cast(bytes); + int u = 0; + while (v >= 1024 && u < 3) { + v /= 1024; + u++; + } + char buf[16]; + if (u == 0 || v >= 10) std::snprintf(buf, sizeof(buf), "%.0f %s", v, units[u]); + else std::snprintf(buf, sizeof(buf), "%.1f %s", v, units[u]); + return buf; +} + +} // namespace roro diff --git a/lib/storage_model/src/cleanup_plan.h b/lib/storage_model/src/cleanup_plan.h new file mode 100644 index 0000000..68c2605 --- /dev/null +++ b/lib/storage_model/src/cleanup_plan.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +struct StoredFile { + std::string path; + uint64_t bytes; +}; + +// What Storage Clean-up offers (see CONTEXT.md). Notes are deliberately absent. +struct CleanupCategory { + const char* label; + const char* folder; +}; +inline const CleanupCategory kCleanupCategories[] = { + {"IRC logs", "/irc"}, + {"Probe-request logs", "/wifi/probes"}, + {"Wi-Fi captures", "/captures/wifi"}, +}; + +enum class CleanupAge { OneMonth, ThreeMonths, SixMonths, OneYear, Everything }; +inline const char* const kCleanupAgeLabels[] = {"Older than 1 month", "Older than 3 months", "Older than 6 months", + "Older than 1 year", "Everything"}; + +// The files a clean-up would delete, and how much space that frees. Dates come from file names; +// undated files are only removed by Everything. +struct CleanupPlan { + std::vector paths; + uint64_t bytes = 0; + + static CleanupPlan make(const std::vector& files, CleanupAge age, int todayDay); +}; + +std::string formatBytes(uint64_t bytes); + +} // namespace roro diff --git a/lib/storage_model/src/storage_paths.cpp b/lib/storage_model/src/storage_paths.cpp new file mode 100644 index 0000000..399ddb3 --- /dev/null +++ b/lib/storage_model/src/storage_paths.cpp @@ -0,0 +1,67 @@ +#include "storage_paths.h" + +#include + +namespace roro::storage { + +namespace { + +// Howard Hinnant's days_from_civil. +int daysFromCivil(int y, int m, int d) { + y -= m <= 2; + int era = (y >= 0 ? y : y - 399) / 400; + int yoe = y - era * 400; + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; +} + +bool digits(const std::string& s, size_t pos, size_t n, int& out) { + if (pos + n > s.size()) return false; + out = 0; + for (size_t i = pos; i < pos + n; i++) { + if (!std::isdigit(static_cast(s[i]))) return false; + out = out * 10 + (s[i] - '0'); + } + return true; +} + +int validDay(int y, int m, int d) { + static const int kDays[] = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (y < 1970 || m < 1 || m > 12 || d < 1 || d > kDays[m - 1]) return -1; + return daysFromCivil(y, m, d); +} + +} // namespace + +std::string sanitize(const std::string& name) { + std::string out; + for (char c : name) { + bool forbidden = c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || + c == '>' || c == '|' || static_cast(c) < 0x20; + out += forbidden ? '_' : c; + } + if (out.empty() || out == ".") return "_"; + if (out == "..") return "_."; + return out; +} + +std::string dailyLogPath(std::initializer_list folders, const std::string& date) { + std::string path; + for (auto& f : folders) path += "/" + sanitize(f); + return path + "/" + (date.empty() ? "undated" : date) + ".log"; +} + +int fileDay(const std::string& name) { + int y, m, d; + if (digits(name, 0, 4, y) && name.size() > 10 && name[4] == '-' && digits(name, 5, 2, m) && name[7] == '-' && + digits(name, 8, 2, d)) + return validDay(y, m, d); + if (digits(name, 0, 4, y) && digits(name, 4, 2, m) && digits(name, 6, 2, d) && name.size() > 8 && name[8] == '-') + return validDay(y, m, d); + return -1; +} + +int dayFromUtc(int64_t utcSeconds) { return static_cast(utcSeconds / 86400); } + +} // namespace roro::storage diff --git a/lib/storage_model/src/storage_paths.h b/lib/storage_model/src/storage_paths.h new file mode 100644 index 0000000..061d070 --- /dev/null +++ b/lib/storage_model/src/storage_paths.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +namespace roro::storage { + +// Makes one path component safe for FAT: forbidden characters become '_', never empty or a dot name. +std::string sanitize(const std::string& name); + +// "/irc/libera/#roro/2026-10-02.log" from {"irc", "libera", "#roro"} and a local date +// ("YYYY-MM-DD"); an empty date (clock not set yet) gives "undated.log". +std::string dailyLogPath(std::initializer_list folders, const std::string& date); + +// Day number (days since 1970-01-01) encoded in a Log ("2026-10-02.log") or Capture +// ("20261002-144500.pcap") file name, or -1 if the name carries no valid date. +int fileDay(const std::string& fileName); + +int dayFromUtc(int64_t utcSeconds); + +} // namespace roro::storage diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 7ee76b8..062c5a8 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -14,7 +14,6 @@ using Row = SettingsMenu::Row; void SettingsApp::onEnter() { page_ = Page::Menu; - eraseDialog_.reset(); list_.setCount(menu_.count()); } @@ -28,7 +27,9 @@ bool SettingsApp::onKey(const KeyEvent& e) { 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::Storage: + if (!storagePage_.onKey(e)) page_ = Page::Menu; + return true; case Page::About: return onAboutKey(e); case Page::Wifi: if (!wifiPage_.onKey(e)) page_ = Page::Menu; @@ -66,7 +67,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) { case Kind::Slider: break; case Kind::Page: switch (menu_.row(i)) { - case Row::Storage: page_ = Page::Storage; break; + case Row::Storage: + page_ = Page::Storage; + storagePage_.enter(); + break; case Row::Wifi: page_ = Page::Wifi; wifiPage_.enter(); @@ -112,21 +116,6 @@ bool SettingsApp::onChoiceKey(const KeyEvent& e) { 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 @@ -142,25 +131,6 @@ void SettingsApp::update(uint32_t nowMs) { } } -std::vector SettingsApp::storageLines() const { - auto s = d_.storage.state(); - std::vector 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(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 SettingsApp::aboutLines() const { std::vector lines; char buf[64]; @@ -210,12 +180,7 @@ void SettingsApp::draw(Canvas& c) { 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::Storage: storagePage_.draw(c); break; case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; case Page::Wifi: wifiPage_.draw(c); break; } diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index 8ad6e08..f822850 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -13,6 +13,7 @@ #include "services/battery_service.h" #include "services/clock_service.h" #include "services/storage_service.h" +#include "apps/storage_page.h" #include "apps/wifi_settings_page.h" #include "settings_menu.h" #include "ui/theme.h" @@ -34,7 +35,10 @@ struct SettingsAppDeps { class SettingsApp : public App { public: explicit SettingsApp(const SettingsAppDeps& deps) - : d_(deps), menu_(deps.settings), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus) {} + : d_(deps), + menu_(deps.settings), + wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus), + storagePage_(deps.storage, deps.clock, deps.bus) {} void onEnter() override; bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; @@ -49,20 +53,18 @@ class SettingsApp : public App { 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 storageLines() const; std::vector aboutLines() const; SettingsAppDeps d_; SettingsMenu menu_; WifiSettingsPage wifiPage_; + StoragePage storagePage_; Page page_ = Page::Menu; ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel choices_{theme::kContent.h / theme::kLineHeight}; LineEditor editor_{39}; - std::unique_ptr eraseDialog_; int editingRow_ = 0; uint32_t lastRefreshMs_ = 0; }; diff --git a/src/apps/storage_page.cpp b/src/apps/storage_page.cpp new file mode 100644 index 0000000..bd87d8f --- /dev/null +++ b/src/apps/storage_page.cpp @@ -0,0 +1,171 @@ +#include "storage_page.h" + +#include "ui/widgets.h" + +namespace roro { + +namespace { +constexpr int kAges = sizeof(kCleanupAgeLabels) / sizeof(kCleanupAgeLabels[0]); +constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]); +} // namespace + +void StoragePage::enter() { + view_ = View::Main; + dialog_.reset(); + main_.setCount(kRows); + main_.select(kCleanUp); +} + +void StoragePage::warn(const char* text) { + bus_.publish(Event::withText(EventType::Notification, text, static_cast(NotificationLevel::Warning))); +} + +uint64_t StoragePage::categoryBytes(int category) const { + uint64_t sum = 0; + if (category < static_cast(listing_.size())) + for (auto& f : listing_[category]) sum += f.bytes; + return sum; +} + +CleanupPlan StoragePage::planFor(int age) const { + int today = clock_.today(); + auto cutoff = static_cast(age); + // Without a clock only "Everything" can be judged. + if (today < 0 && cutoff != CleanupAge::Everything) return {}; + return CleanupPlan::make(listing_[categories_.selected()], cutoff, today); +} + +bool StoragePage::onKey(const KeyEvent& e) { + if (dialog_) { + dialog_->onKey(e); + int result = dialog_->result(); + if (result == 1) { + if (view_ == View::Main) { + storage_.requestFormat(); + } else { + storage_.requestDelete(pending_.paths); + enter(); + } + } + if (result != DialogModel::kPending) dialog_.reset(); + return true; + } + + switch (view_) { + case View::Main: + switch (e.key) { + case Key::Up: main_.up(); break; + case Key::Down: main_.down(); break; + case Key::Back: return false; + case Key::Select: + if (!storage_.state().present) { + warn("No SD card"); + } else if (main_.selected() == kCleanUp) { + storage_.requestListing(); + haveListing_ = false; + categories_.setCount(kCategories); + view_ = View::Categories; + } else if (main_.selected() == kErase) { + dialog_.reset(new DialogModel({"Cancel", "Erase"})); + } + break; + default: break; + } + return true; + + case View::Categories: + switch (e.key) { + case Key::Up: categories_.up(); break; + case Key::Down: categories_.down(); break; + case Key::Back: view_ = View::Main; break; + case Key::Select: + if (!haveListing_) break; + if (listing_[categories_.selected()].empty()) { + warn("Nothing to clean up here"); + break; + } + ages_.setCount(kAges); + view_ = View::Ages; + break; + default: break; + } + return true; + + case View::Ages: + switch (e.key) { + case Key::Up: ages_.up(); break; + case Key::Down: ages_.down(); break; + case Key::Back: view_ = View::Categories; break; + case Key::Select: + pending_ = planFor(ages_.selected()); + if (pending_.paths.empty()) warn(clock_.today() < 0 ? "Clock not set: only Everything works" + : "No files that old"); + else dialog_.reset(new DialogModel({"Cancel", "Delete"})); + break; + default: break; + } + return true; + } + return true; +} + +void StoragePage::draw(Canvas& c) { + const auto& area = theme::kContent; + if (!haveListing_ && storage_.listingReady()) { + listing_ = storage_.listing(); + haveListing_ = true; + } + + switch (view_) { + case View::Main: { + auto s = storage_.state(); + widgets::list( + c, main_, area, + [](int i) -> std::string { + const char* labels[] = {"SD card", "Logs", "Captures", "Clean up", "Erase SD card"}; + return labels[i]; + }, + [&](int i) -> std::string { + if (storage_.formatting() && i == kCard) return "erasing..."; + if (!s.present) return i == kCard ? "none" : ""; + switch (i) { + case kCard: return formatBytes(s.totalBytes) + ", " + std::to_string(s.usedPercent) + "% used"; + case kLogs: return s.logsAllowed ? "recording" : "paused (over 90%)"; + case kCaptures: return s.capturesAllowed ? "allowed" : "stopped (full)"; + default: return ">"; + } + }); + if (dialog_) + widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.", + *dialog_); + break; + } + case View::Categories: + if (!haveListing_) { + widgets::textLines(c, {"Listing files..."}, 0, area); + break; + } + widgets::list( + c, categories_, area, [](int i) { return std::string(kCleanupCategories[i].label); }, + [this](int i) { + return std::to_string(listing_[i].size()) + " files, " + formatBytes(categoryBytes(i)); + }); + break; + case View::Ages: + widgets::list( + c, ages_, area, [](int i) { return std::string(kCleanupAgeLabels[i]); }, + [this](int i) { + auto plan = planFor(i); + return plan.paths.empty() ? std::string("-") : formatBytes(plan.bytes); + }); + if (dialog_) { + std::string message = "Delete " + std::to_string(pending_.paths.size()) + " files from " + + kCleanupCategories[categories_.selected()].label + ", freeing " + + formatBytes(pending_.bytes) + "?"; + widgets::dialog(c, "Clean up", message, *dialog_); + } + break; + } +} + +} // namespace roro diff --git a/src/apps/storage_page.h b/src/apps/storage_page.h new file mode 100644 index 0000000..8de205c --- /dev/null +++ b/src/apps/storage_page.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#include "cleanup_plan.h" +#include "dialog_model.h" +#include "event_bus.h" +#include "key_event.h" +#include "list_model.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "ui/canvas.h" +#include "ui/theme.h" + +namespace roro { + +// Settings → Storage: card usage, Storage Clean-up (category → age → confirm) and erasing the card. +class StoragePage { + public: + StoragePage(StorageService& storage, ClockService& clock, EventBus& bus) + : storage_(storage), clock_(clock), bus_(bus) {} + + void enter(); + bool onKey(const KeyEvent& e); // false: leave the page + void draw(Canvas& c); + + private: + enum class View { Main, Categories, Ages }; + enum Row { kCard, kLogs, kCaptures, kCleanUp, kErase, kRows }; + + CleanupPlan planFor(int age) const; + uint64_t categoryBytes(int category) const; + void warn(const char* text); + + StorageService& storage_; + ClockService& clock_; + EventBus& bus_; + View view_ = View::Main; + ListModel main_{theme::kContent.h / theme::kLineHeight}; + ListModel categories_{theme::kContent.h / theme::kLineHeight}; + ListModel ages_{theme::kContent.h / theme::kLineHeight}; + std::vector> listing_; + bool haveListing_ = false; + std::unique_ptr dialog_; + CleanupPlan pending_; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index bc06c5b..48bedf8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include "services/storage_service.h" #include "services/wifi_service.h" #include "settings.h" +#include "storage_paths.h" #include "ui/notifier.h" #include "ui/screen.h" #include "version.h" @@ -31,7 +32,7 @@ static Screen screen; // Constructed in setup(), after the hardware and Settings are ready. static BatteryService* battery; -static StorageService* storage; +static StorageService* storageService; static PowerService* power; static ClockService* clockService; static SavedNetworks* savedNetworks; @@ -61,7 +62,7 @@ static StatusInfo currentStatus() { if (apps->foregroundTitle()) s.title = apps->foregroundTitle(); if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent(); s.clock = clockService->displayTime(); - auto storageState = storage->state(); + auto storageState = storageService->state(); s.sdPresent = storageState.present; s.sdLevel = storageState.level; s.compose = keyMapper.pendingCompose(); @@ -90,7 +91,7 @@ void setup() { settings.load(); battery = new BatteryService(bus); - storage = new StorageService(bus); + storageService = new StorageService(bus); power = new PowerService(settings); clockService = new ClockService(settings, bus); savedNetworks = new SavedNetworks(nvs); @@ -101,13 +102,13 @@ void setup() { services.add(*power); services.add(*clockService); services.add(*battery); - services.add(*storage); + services.add(*storageService); services.add(*wifi); apps = new AppManager(launcher); launcher.setManager(*apps); apps->registerApp({"settings", "Settings", false, - new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService, *wifi, *savedNetworks})}); + new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); @@ -121,6 +122,21 @@ void setup() { } // Dev aid: serial commands to drive the UI without the keyboard. +static bool listingWanted = false; + +static void printListingWhenReady() { + if (!listingWanted || !storageService->listingReady()) return; + listingWanted = false; + auto listing = storageService->listing(); + for (size_t i = 0; i < listing.size(); i++) { + uint64_t bytes = 0; + for (auto& f : listing[i]) bytes += f.bytes; + Serial.printf("sd list: %-20s %u files, %s\n", kCleanupCategories[i].label, (unsigned)listing[i].size(), + formatBytes(bytes).c_str()); + for (auto& f : listing[i]) Serial.printf(" %s (%u B)\n", f.path.c_str(), (unsigned)f.bytes); + } +} + static void serialCommands() { static String line; while (Serial.available()) { @@ -147,6 +163,15 @@ static void serialCommands() { Serial.printf("wifi add: %s\n", error.empty() ? "ok" : error.c_str()); wifi->savedNetworksChanged(); } + if (line.startsWith("log ")) { // appends to a test IRC Log + std::string path = storage::dailyLogPath({"irc", "dev", "#test"}, clockService->localDate()); + storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str()); + Serial.printf("log: queued to %s\n", path.c_str()); + } + if (line == "sd list") { + storageService->requestListing(); + listingWanted = true; + } 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(), @@ -171,6 +196,7 @@ void loop() { uint32_t now = millis(); serialCommands(); + printListingWhenReady(); M5Cardputer.update(); if (M5Cardputer.Keyboard.isChange()) { bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen diff --git a/src/services/clock_service.h b/src/services/clock_service.h index a9d6222..f830862 100644 --- a/src/services/clock_service.h +++ b/src/services/clock_service.h @@ -23,6 +23,11 @@ class ClockService : public Service { bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } const ClockModel& model() const { return model_; } + // Local "YYYY-MM-DD" once set; otherwise empty (Logs then go to undated files). + std::string localDate() const { return model_.isSet() ? ClockModel::formatLocalDate(model_.utcNow(millis())) : ""; } + // Days since 1970-01-01 (UTC), or -1 if the clock isn't set. + int today() const { return model_.isSet() ? static_cast(model_.utcNow(millis()) / 86400) : -1; } + // "14:05" once set; otherwise "--:--". std::string displayTime() const { return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--"; diff --git a/src/services/storage_service.cpp b/src/services/storage_service.cpp index 08ed21c..cfacfc7 100644 --- a/src/services/storage_service.cpp +++ b/src/services/storage_service.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "platform/pins.h" @@ -14,7 +15,7 @@ namespace roro { void StorageService::start() { if (task_) return; lock_ = xSemaphoreCreateMutex(); - xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_); + xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_); } void StorageService::stop() { @@ -26,23 +27,52 @@ void StorageService::stop() { } StorageState StorageService::state() const { - xSemaphoreTake(lock_, portMAX_DELAY); + lock(); StorageState copy = monitor_.state(); - xSemaphoreGive(lock_); + unlock(); return copy; } -void StorageService::taskEntry(void* self) { - auto* service = static_cast(self); - for (;;) { - if (service->formatRequested_) { - service->format(); - service->formatRequested_ = false; - } - service->poll(); - // Sleep until the next poll, or until a format request wakes us early. - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPollMs)); +void StorageService::appendLine(const std::string& path, const std::string& line) { + lock(); + size_t bytes = path.size() + line.size(); + bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes; + if (accept) { + pending_.emplace_back(path, line); + pendingBytes_ += bytes; + } else { + dropped_++; } + unlock(); +} + +void StorageService::requestListing() { + lock(); + listingRequested_ = true; + listingReady_ = false; + unlock(); + if (task_) xTaskNotifyGive(task_); +} + +bool StorageService::listingReady() const { + lock(); + bool ready = listingReady_; + unlock(); + return ready; +} + +std::vector> StorageService::listing() const { + lock(); + auto copy = listing_; + unlock(); + return copy; +} + +void StorageService::requestDelete(std::vector paths) { + lock(); + toDelete_.insert(toDelete_.end(), paths.begin(), paths.end()); + unlock(); + if (task_) xTaskNotifyGive(task_); } bool StorageService::requestFormat() { @@ -52,6 +82,38 @@ bool StorageService::requestFormat() { return true; } +void StorageService::taskEntry(void* self) { static_cast(self)->loop(); } + +void StorageService::loop() { + uint32_t wakes = kPollEvery; // poll right away + for (;;) { + if (formatRequested_) { + format(); + formatRequested_ = false; + wakes = kPollEvery; + } + if (wakes++ >= kPollEvery) { + wakes = 1; + poll(); + } + writePending(); + + lock(); + bool wantListing = listingRequested_; + std::vector deleting; + deleting.swap(toDelete_); + unlock(); + if (!deleting.empty()) { + remove(deleting); + poll(); + } + if (wantListing) list(); + + // Sleep until the next batch, or until a request wakes us early. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kWakeMs)); + } +} + void StorageService::format() { if (mounted_) SD.end(); mounted_ = false; @@ -91,9 +153,76 @@ void StorageService::poll() { uint64_t total = present ? SD.totalBytes() : 0; uint64_t used = present ? SD.usedBytes() : 0; - xSemaphoreTake(lock_, portMAX_DELAY); + lock(); monitor_.update(present, total, used); - xSemaphoreGive(lock_); + unlock(); +} + +void StorageService::writePending() { + lock(); + std::deque> batch; + batch.swap(pending_); + pendingBytes_ = 0; + unlock(); + if (batch.empty() || !mounted_) return; + + // Keep each file's lines in order while opening each file once. + std::stable_sort(batch.begin(), batch.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); + File file; + std::string openPath; + for (auto& [path, line] : batch) { + if (path != openPath) { + if (file) file.close(); + file = SD.open(path.c_str(), FILE_APPEND, true); // true: create missing folders + openPath = path; + } + if (file) { + file.write(reinterpret_cast(line.data()), line.size()); + file.write('\n'); + } + } + if (file) file.close(); +} + +namespace { +void walk(File dir, std::vector& out) { + for (File f = dir.openNextFile(); f; f = dir.openNextFile()) { + if (f.isDirectory()) walk(f, out); + else out.push_back({f.path(), f.size()}); + } +} +} // namespace + +void StorageService::list() { + std::vector> result; + for (auto& category : kCleanupCategories) { + std::vector files; + if (mounted_) { + File dir = SD.open(category.folder); + if (dir && dir.isDirectory()) walk(dir, files); + } + result.push_back(std::move(files)); + } + lock(); + listing_ = std::move(result); + listingRequested_ = false; + listingReady_ = true; + unlock(); +} + +void StorageService::remove(const std::vector& paths) { + uint64_t freed = 0; + int failed = 0; + for (auto& p : paths) { + File f = SD.open(p.c_str()); + uint64_t size = f ? f.size() : 0; + if (f) f.close(); + if (SD.remove(p.c_str())) freed += size; + else failed++; + } + std::string text = "Freed " + formatBytes(freed) + (failed ? " (" + std::to_string(failed) + " failed)" : ""); + bus_.publish(Event::withText(EventType::Notification, text.c_str(), + static_cast(failed ? NotificationLevel::Warning : NotificationLevel::Info))); } } // namespace roro diff --git a/src/services/storage_service.h b/src/services/storage_service.h index b3caa9a..c280f1a 100644 --- a/src/services/storage_service.h +++ b/src/services/storage_service.h @@ -3,13 +3,20 @@ #include #include +#include +#include +#include +#include + +#include "cleanup_plan.h" #include "service.h" #include "storage_monitor.h" namespace roro { -// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor. -// Runs on its own task: a failed mount blocks for seconds and must never stall the UI. +// Owns the microSD card. All card access happens on this Service's own task (the SD driver isn't +// safe across tasks, and a failed mount blocks for seconds): it mounts and watches usage, writes +// queued Log lines in batches, lists files for Storage Clean-up, deletes, and formats. class StorageService : public Service { public: explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {} @@ -19,24 +26,52 @@ class StorageService : public Service { StorageState state() const; - // Erases the whole card: one partition spanning the card, formatted FAT32. Runs on the storage - // task; the result arrives as a Notification. Returns false if a format is already pending. + // Queues one line for a Log file (created with its folders as needed). Dropped while Logs are + // paused (over 90 % full, or no card) or if too much is already waiting. + void appendLine(const std::string& path, const std::string& line); + uint32_t droppedLines() const { return dropped_; } + + // Storage Clean-up: list every file of every category, then delete a selection. + void requestListing(); + bool listingReady() const; + std::vector> listing() const; // one vector per kCleanupCategories entry + void requestDelete(std::vector paths); + + // Erases the whole card: one partition spanning the card, formatted FAT32. bool requestFormat(); bool formatting() const { return formatRequested_; } private: - static constexpr uint32_t kPollMs = 15000; + static constexpr uint32_t kWakeMs = 1000; + static constexpr uint32_t kPollEvery = 15; // wake-ups between usage checks + static constexpr size_t kMaxPendingBytes = 16 * 1024; + static void taskEntry(void* self); + void loop(); void poll(); bool mount(); void format(); + void writePending(); + void list(); + void remove(const std::vector& paths); + void lock() const { xSemaphoreTake(lock_, portMAX_DELAY); } + void unlock() const { xSemaphoreGive(lock_); } StorageMonitor monitor_; - bool mounted_ = false; EventBus& bus_; - volatile bool formatRequested_ = false; + bool mounted_ = false; TaskHandle_t task_ = nullptr; mutable SemaphoreHandle_t lock_ = nullptr; + + // Shared with other tasks, guarded by lock_. + std::deque> pending_; + size_t pendingBytes_ = 0; + uint32_t dropped_ = 0; + bool listingRequested_ = false; + bool listingReady_ = false; + std::vector> listing_; + std::vector toDelete_; + volatile bool formatRequested_ = false; }; } // namespace roro diff --git a/test/test_cleanup_plan/test_cleanup_plan.cpp b/test/test_cleanup_plan/test_cleanup_plan.cpp new file mode 100644 index 0000000..eadae6d --- /dev/null +++ b/test/test_cleanup_plan/test_cleanup_plan.cpp @@ -0,0 +1,74 @@ +#include + +#include "cleanup_plan.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static const int kToday = 20728; // 2026-10-02 + +static std::vector files() { + return { + {"/irc/libera/#roro/2026-10-01.log", 1000}, // 1 day old + {"/irc/libera/#roro/2026-08-01.log", 2000}, // 62 days + {"/irc/libera/#roro/2025-09-01.log", 4000}, // 396 days + {"/irc/libera/#roro/undated.log", 8000}, + }; +} + +void test_age_cutoffs_select_strictly_older_files() { + auto plan = CleanupPlan::make(files(), CleanupAge::OneMonth, kToday); + TEST_ASSERT_EQUAL(2, plan.paths.size()); + TEST_ASSERT_EQUAL(6000, plan.bytes); +} + +void test_a_year_keeps_everything_younger() { + auto plan = CleanupPlan::make(files(), CleanupAge::OneYear, kToday); + TEST_ASSERT_EQUAL(1, plan.paths.size()); + TEST_ASSERT_EQUAL_STRING("/irc/libera/#roro/2025-09-01.log", plan.paths[0].c_str()); +} + +void test_undated_files_only_go_with_everything() { + auto plan = CleanupPlan::make(files(), CleanupAge::Everything, kToday); + TEST_ASSERT_EQUAL(4, plan.paths.size()); + TEST_ASSERT_EQUAL(15000, plan.bytes); +} + +void test_nothing_to_delete_is_an_empty_plan() { + auto plan = CleanupPlan::make({{"/irc/a/b/2026-10-02.log", 10}}, CleanupAge::OneMonth, kToday); + TEST_ASSERT_TRUE(plan.paths.empty()); + TEST_ASSERT_EQUAL(0, plan.bytes); +} + +void test_notes_are_never_selected() { + auto plan = CleanupPlan::make({{"/notes/2020-01-01.log", 10}}, CleanupAge::Everything, kToday); + TEST_ASSERT_TRUE(plan.paths.empty()); +} + +void test_categories_cover_logs_and_captures_but_not_notes() { + bool notes = false; + for (auto& c : kCleanupCategories) notes |= std::string(c.folder) == "/notes"; + TEST_ASSERT_FALSE(notes); + TEST_ASSERT_TRUE(sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]) >= 3); +} + +void test_size_formatting() { + TEST_ASSERT_EQUAL_STRING("512 B", formatBytes(512).c_str()); + TEST_ASSERT_EQUAL_STRING("1.5 KB", formatBytes(1536).c_str()); + TEST_ASSERT_EQUAL_STRING("340 MB", formatBytes(340ull * 1024 * 1024).c_str()); + TEST_ASSERT_EQUAL_STRING("2.1 GB", formatBytes(2254857830ull).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_age_cutoffs_select_strictly_older_files); + RUN_TEST(test_a_year_keeps_everything_younger); + RUN_TEST(test_undated_files_only_go_with_everything); + RUN_TEST(test_nothing_to_delete_is_an_empty_plan); + RUN_TEST(test_notes_are_never_selected); + RUN_TEST(test_categories_cover_logs_and_captures_but_not_notes); + RUN_TEST(test_size_formatting); + return UNITY_END(); +} diff --git a/test/test_clock/test_clock.cpp b/test/test_clock/test_clock.cpp index 197f391..4152297 100644 --- a/test/test_clock/test_clock.cpp +++ b/test/test_clock/test_clock.cpp @@ -61,6 +61,12 @@ void test_local_time_in_brussels_summer_and_winter() { TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str()); } +void test_local_date_in_brussels() { + ClockModel::applyTimezone(kBrussels); + // 2026-07-01 23:30Z is already July 2nd in Brussels (UTC+2) + TEST_ASSERT_EQUAL_STRING("2026-07-02", ClockModel::formatLocalDate(k2026_07_01_1200Z + 11 * 3600 + 1800).c_str()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_clock_is_unset_until_a_source_sets_it); @@ -70,5 +76,6 @@ int main() { RUN_TEST(test_utc_now_survives_millis_wraparound); RUN_TEST(test_relative_age_formatting); RUN_TEST(test_local_time_in_brussels_summer_and_winter); + RUN_TEST(test_local_date_in_brussels); return UNITY_END(); } diff --git a/test/test_storage_paths/test_storage_paths.cpp b/test/test_storage_paths/test_storage_paths.cpp new file mode 100644 index 0000000..c98e998 --- /dev/null +++ b/test/test_storage_paths/test_storage_paths.cpp @@ -0,0 +1,54 @@ +#include + +#include "storage_paths.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_sanitize_replaces_characters_fat_forbids() { + TEST_ASSERT_EQUAL_STRING("a_b_c_d_e_f_g_h_i", storage::sanitize("a\\b/c:d*e?f\"gi").c_str()); + TEST_ASSERT_EQUAL_STRING("#roro", storage::sanitize("#roro").c_str()); + TEST_ASSERT_EQUAL_STRING("Cl\xC3\xA9ment", storage::sanitize("Cl\xC3\xA9ment").c_str()); +} + +void test_sanitize_never_returns_empty_or_dot_names() { + TEST_ASSERT_EQUAL_STRING("_", storage::sanitize("").c_str()); + TEST_ASSERT_EQUAL_STRING("_", storage::sanitize(".").c_str()); + TEST_ASSERT_EQUAL_STRING("_.", storage::sanitize("..").c_str()); +} + +void test_daily_log_path() { + TEST_ASSERT_EQUAL_STRING("/irc/libera/#roro/2026-10-02.log", + storage::dailyLogPath({"irc", "libera", "#roro"}, "2026-10-02").c_str()); +} + +void test_daily_log_path_sanitizes_each_part() { + TEST_ASSERT_EQUAL_STRING("/irc/irc.libera.chat_6697/nick_name/undated.log", + storage::dailyLogPath({"irc", "irc.libera.chat:6697", "nick/name"}, "").c_str()); +} + +void test_parse_date_from_log_and_capture_names() { + TEST_ASSERT_EQUAL(20728, storage::fileDay("2026-10-02.log")); // days since 1970-01-01 + TEST_ASSERT_EQUAL(20728, storage::fileDay("20261002-144500.pcap")); + TEST_ASSERT_EQUAL(-1, storage::fileDay("undated.log")); + TEST_ASSERT_EQUAL(-1, storage::fileDay("notes.txt")); + TEST_ASSERT_EQUAL(-1, storage::fileDay("2026-13-02.log")); +} + +void test_day_from_utc_seconds() { + TEST_ASSERT_EQUAL(20728, storage::dayFromUtc(1790942400)); // 2026-10-02 12:00Z + TEST_ASSERT_EQUAL(0, storage::dayFromUtc(0)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_sanitize_replaces_characters_fat_forbids); + RUN_TEST(test_sanitize_never_returns_empty_or_dot_names); + RUN_TEST(test_daily_log_path); + RUN_TEST(test_daily_log_path_sanitizes_each_part); + RUN_TEST(test_parse_date_from_log_and_capture_names); + RUN_TEST(test_day_from_utc_seconds); + return UNITY_END(); +} From 311eb387235791246f856358169d48e857d57873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 15:03:21 +0200 Subject: [PATCH 4/9] M1 step 4a: IRC protocol core (host-tested) lib/irc: IrcMessage parse/serialize, base64, ReconnectPolicy (5 s .. 5 min), IrcConfig (validated, persisted) and IrcSession: registration with SASL PLAIN or NickServ, nick fallback, PING, Buffers capped at 50 lines with unread counts, Mentions, CTCP ACTION/VERSION, topics, /names on request only, others' join/part/quit hidden, user commands, and rejoining after a pause. No networking: the session returns lines to send, Log entries and Notifications. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/irc/src/base64.h | 31 ++ lib/irc/src/irc_config.cpp | 79 +++++ lib/irc/src/irc_config.h | 36 +++ lib/irc/src/irc_message.cpp | 43 +++ lib/irc/src/irc_message.h | 23 ++ lib/irc/src/irc_session.cpp | 318 +++++++++++++++++++ lib/irc/src/irc_session.h | 98 ++++++ lib/irc/src/reconnect_policy.h | 21 ++ test/test_irc_config/test_irc_config.cpp | 74 +++++ test/test_irc_message/test_irc_message.cpp | 74 +++++ test/test_irc_session/test_irc_session.cpp | 345 +++++++++++++++++++++ 11 files changed, 1142 insertions(+) create mode 100644 lib/irc/src/base64.h create mode 100644 lib/irc/src/irc_config.cpp create mode 100644 lib/irc/src/irc_config.h create mode 100644 lib/irc/src/irc_message.cpp create mode 100644 lib/irc/src/irc_message.h create mode 100644 lib/irc/src/irc_session.cpp create mode 100644 lib/irc/src/irc_session.h create mode 100644 lib/irc/src/reconnect_policy.h create mode 100644 test/test_irc_config/test_irc_config.cpp create mode 100644 test/test_irc_message/test_irc_message.cpp create mode 100644 test/test_irc_session/test_irc_session.cpp diff --git a/lib/irc/src/base64.h b/lib/irc/src/base64.h new file mode 100644 index 0000000..62665ad --- /dev/null +++ b/lib/irc/src/base64.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace roro { + +inline std::string base64Encode(const std::string& in) { + static const char* kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + size_t i = 0; + while (i + 2 < in.size()) { + uint32_t n = (static_cast(in[i]) << 16) | (static_cast(in[i + 1]) << 8) | static_cast(in[i + 2]); + out += kAlphabet[n >> 18]; + out += kAlphabet[(n >> 12) & 63]; + out += kAlphabet[(n >> 6) & 63]; + out += kAlphabet[n & 63]; + i += 3; + } + size_t rest = in.size() - i; + if (rest) { + uint32_t n = static_cast(in[i]) << 16; + if (rest == 2) n |= static_cast(in[i + 1]) << 8; + out += kAlphabet[n >> 18]; + out += kAlphabet[(n >> 12) & 63]; + out += rest == 2 ? kAlphabet[(n >> 6) & 63] : '='; + out += '='; + } + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_config.cpp b/lib/irc/src/irc_config.cpp new file mode 100644 index 0000000..6a380f1 --- /dev/null +++ b/lib/irc/src/irc_config.cpp @@ -0,0 +1,79 @@ +#include "irc_config.h" + +#include +#include + +namespace roro { + +namespace { +bool validNick(const std::string& n) { + if (n.empty() || n.size() > 30) return false; + if (std::isdigit(static_cast(n[0])) || n[0] == '-') return false; + for (char c : n) + if (!std::isalnum(static_cast(c)) && !std::strchr("[]\\`_^{|}-", c)) return false; + return true; +} +} // namespace + +void IrcConfig::load(const std::string& defaultNick) { + nick = defaultNick; + std::string s; + int32_t i; + if (store_.getString("irc_host", s)) host = s; + if (store_.getInt("irc_port", i)) port = i; + if (store_.getInt("irc_tls", i)) tls = i != 0; + if (store_.getInt("irc_selfsign", i)) allowSelfSigned = i != 0; + store_.getString("irc_pin", pinnedSha256); + if (store_.getString("irc_nick", s)) nick = s; + store_.getString("irc_sasl_user", saslUser); + store_.getString("irc_sasl_pass", saslPassword); + store_.getString("irc_ns_pass", nickservPassword); + if (store_.getString("irc_join", s)) autojoin = parseChannels(s); +} + +std::string IrcConfig::validate() const { + if (host.empty() || host.size() > 63) return "Server must be 1 to 63 characters"; + if (port < 1 || port > 65535) return "Port must be 1 to 65535"; + if (!validNick(nick)) return "Nick: letters, digits and []\\`_^{|}- only, not starting with a digit"; + for (auto& c : autojoin) + if (c.size() < 2 || (c[0] != '#' && c[0] != '&')) return "IRC channels start with # or &"; + return ""; +} + +std::string IrcConfig::save() { + std::string error = validate(); + if (!error.empty()) return error; + store_.putString("irc_host", host); + store_.putInt("irc_port", port); + store_.putInt("irc_tls", tls ? 1 : 0); + store_.putInt("irc_selfsign", allowSelfSigned ? 1 : 0); + store_.putString("irc_pin", pinnedSha256); + store_.putString("irc_nick", nick); + store_.putString("irc_sasl_user", saslUser); + store_.putString("irc_sasl_pass", saslPassword); + store_.putString("irc_ns_pass", nickservPassword); + store_.putString("irc_join", formatChannels(autojoin)); + return ""; +} + +std::vector IrcConfig::parseChannels(const std::string& text) { + std::vector out; + std::string current; + for (char c : text + " ") { + if (c == ' ' || c == ',') { + if (!current.empty()) out.push_back(current); + current.clear(); + } else { + current += c; + } + } + return out; +} + +std::string IrcConfig::formatChannels(const std::vector& channels) { + std::string out; + for (auto& c : channels) out += (out.empty() ? "" : " ") + c; + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_config.h b/lib/irc/src/irc_config.h new file mode 100644 index 0000000..924218f --- /dev/null +++ b/lib/irc/src/irc_config.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "key_value_store.h" + +namespace roro { + +// The one IRC server the IRC Service connects to, persisted in internal flash. +class IrcConfig { + public: + explicit IrcConfig(KeyValueStore& store) : store_(store) {} + + std::string host = "irc.libera.chat"; + int port = 6697; + bool tls = true; + bool allowSelfSigned = false; // pin the certificate on first use instead of checking a CA + std::string pinnedSha256; // hex fingerprint once pinned + std::string nick; + std::string saslUser, saslPassword; // SASL PLAIN when both are set + std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set + std::vector autojoin; + + void load(const std::string& defaultNick); + std::string save(); // empty on success, otherwise why it was refused + std::string validate() const; + + static std::vector parseChannels(const std::string& text); + static std::string formatChannels(const std::vector& channels); + + private: + KeyValueStore& store_; +}; + +} // namespace roro diff --git a/lib/irc/src/irc_message.cpp b/lib/irc/src/irc_message.cpp new file mode 100644 index 0000000..9bdf229 --- /dev/null +++ b/lib/irc/src/irc_message.cpp @@ -0,0 +1,43 @@ +#include "irc_message.h" + +namespace roro { + +IrcMessage IrcMessage::parse(const std::string& raw) { + IrcMessage m; + std::string line = raw; + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + + size_t pos = 0; + auto word = [&]() { + size_t end = line.find(' ', pos); + std::string w = line.substr(pos, end == std::string::npos ? std::string::npos : end - pos); + pos = end == std::string::npos ? line.size() : end + 1; + while (pos < line.size() && line[pos] == ' ') pos++; + return w; + }; + if (pos < line.size() && line[pos] == '@') word(); + if (pos < line.size() && line[pos] == ':') m.prefix = word().substr(1); + m.command = word(); + while (pos < line.size()) { + if (line[pos] == ':') { + m.params.push_back(line.substr(pos + 1)); + break; + } + m.params.push_back(word()); + } + return m; +} + +std::string IrcMessage::serialize(const std::string& command, std::initializer_list params) { + std::string out = command; + size_t i = 0; + for (auto& p : params) { + bool last = ++i == params.size(); + out += ' '; + if (last && (p.empty() || p[0] == ':' || p.find(' ') != std::string::npos)) out += ':'; + out += p; + } + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_message.h b/lib/irc/src/irc_message.h new file mode 100644 index 0000000..9f4e768 --- /dev/null +++ b/lib/irc/src/irc_message.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// One IRC protocol line: [@tags] [:prefix] COMMAND params... [:trailing] (tags are ignored). +struct IrcMessage { + std::string prefix; + std::string command; + std::vector params; + + std::string nick() const { return prefix.substr(0, prefix.find('!')); } + std::string param(size_t i) const { return i < params.size() ? params[i] : ""; } + + static IrcMessage parse(const std::string& line); + // Builds a line; the last parameter gets a ':' when it needs one. + static std::string serialize(const std::string& command, std::initializer_list params); +}; + +} // namespace roro diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp new file mode 100644 index 0000000..501c84c --- /dev/null +++ b/lib/irc/src/irc_session.cpp @@ -0,0 +1,318 @@ +#include "irc_session.h" + +#include +#include + +#include "base64.h" + +namespace roro { + +namespace { + +const char kCtcp = '\x01'; + +std::string lower(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +bool isChannel(const std::string& name) { return !name.empty() && (name[0] == '#' || name[0] == '&'); } + +bool nickChar(char c) { return std::isalnum(static_cast(c)) || std::string("[]\\`_^{|}-").find(c) != std::string::npos; } + +std::string join(const std::vector& params, size_t from) { + std::string out; + for (size_t i = from; i < params.size(); i++) out += (out.empty() ? "" : " ") + params[i]; + return out; +} + +// Splits "word rest of text" into the first word and the rest. +std::pair firstWord(const std::string& text) { + size_t space = text.find(' '); + if (space == std::string::npos) return {text, ""}; + return {text.substr(0, space), text.substr(space + 1)}; +} + +} // namespace + +IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) { + buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""}); +} + +int IrcSession::totalUnread() const { + int n = 0; + for (auto& b : buffers_) n += b.unread; + return n; +} + +IrcEffects IrcSession::takeEffects() { + IrcEffects out; + std::swap(out, effects_); + return out; +} + +int IrcSession::findBuffer(const std::string& name) const { + for (int i = 0; i < bufferCount(); i++) + if (lower(buffers_[i].name) == lower(name)) return i; + return -1; +} + +int IrcSession::bufferFor(const std::string& name, IrcBuffer::Type type) { + int i = findBuffer(name); + if (i >= 0) return i; + if (bufferCount() >= kMaxBuffers) return 0; // full: fall back to the server Buffer + buffers_.push_back({name, type, {}, 0, false, false, ""}); + return bufferCount() - 1; +} + +void IrcSession::setViewing(int buffer) { + viewing_ = buffer; + if (buffer >= 0 && buffer < bufferCount()) { + buffers_[buffer].unread = 0; + buffers_[buffer].mentioned = false; + } +} + +bool IrcSession::mentionsMe(const std::string& text) const { + std::string hay = lower(text), needle = lower(nick_); + for (size_t pos = hay.find(needle); pos != std::string::npos; pos = hay.find(needle, pos + 1)) { + bool startOk = pos == 0 || !nickChar(hay[pos - 1]); + size_t end = pos + needle.size(); + bool endOk = end >= hay.size() || !nickChar(hay[end]); + if (startOk && endOk) return true; + } + return false; +} + +void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc, + bool mention) { + IrcBuffer& buf = buffers_[b]; + IrcLine line{utc, kind, nick, text}; + buf.lines.push_back(line); + if (buf.lines.size() > kLinesPerBuffer) buf.lines.pop_front(); + effects_.logs.push_back({buf.name, line}); + + bool fromOthers = kind == IrcLine::Kind::Message || kind == IrcLine::Kind::Action || kind == IrcLine::Kind::Notice; + if (b != viewing_ && fromOthers) { + buf.unread++; + if (mention) { + buf.mentioned = true; + std::string where = buf.type == IrcBuffer::Type::Query ? nick : nick + " in " + buf.name; + effects_.notifications.push_back(where + ": " + text); + } + } +} + +void IrcSession::connected(int64_t) { + registered_ = false; + quit_ = false; + nick_ = config_.nick; + if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl"); + send(IrcMessage::serialize("NICK", {nick_})); + send(IrcMessage::serialize("USER", {nick_, "0", "*", "roro9stack"})); +} + +void IrcSession::disconnected(int64_t utc, const std::string& reason) { + registered_ = false; + rejoin_.clear(); + for (int i = 0; i < bufferCount(); i++) { + if (buffers_[i].type == IrcBuffer::Type::Channel && buffers_[i].joined) rejoin_.push_back(buffers_[i].name); + if (buffers_[i].type == IrcBuffer::Type::Channel) buffers_[i].joined = false; + info(i, "-- " + reason + " --", utc); + } +} + +void IrcSession::onWelcome(const IrcMessage& m, int64_t utc) { + registered_ = true; + nick_ = m.param(0); + info(0, "Connected to " + config_.host + " as " + nick_, utc); + if (!config_.nickservPassword.empty() && config_.saslUser.empty()) + send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + config_.nickservPassword})); + + std::vector channels = config_.autojoin; + for (auto& c : rejoin_) + if (std::find(channels.begin(), channels.end(), c) == channels.end()) channels.push_back(c); + rejoin_.clear(); + if (!channels.empty()) { + std::string list; + for (auto& c : channels) list += (list.empty() ? "" : ",") + c; + send(IrcMessage::serialize("JOIN", {list})); + } +} + +void IrcSession::receive(const std::string& raw, int64_t utc) { + IrcMessage m = IrcMessage::parse(raw); + const std::string& cmd = m.command; + bool fromMe = lower(m.nick()) == lower(nick_); + + if (cmd == "PING") { + send(IrcMessage::serialize("PONG", {m.param(0)})); + } else if (cmd == "001") { + onWelcome(m, utc); + } else if (cmd == "433" && !registered_) { + nick_ += "_"; + send(IrcMessage::serialize("NICK", {nick_})); + } else if (cmd == "CAP") { + std::string sub = m.param(1); + if (sub == "ACK" && m.param(2).find("sasl") != std::string::npos) send("AUTHENTICATE PLAIN"); + else if (sub == "NAK") send("CAP END"); + } else if (cmd == "AUTHENTICATE" && m.param(0) == "+") { + std::string user = config_.saslUser; + send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword)); + } else if (cmd == "903") { + info(0, "SASL login succeeded", utc); + send("CAP END"); + } else if (cmd == "904" || cmd == "905" || cmd == "906" || cmd == "902") { + info(0, "SASL login failed: " + m.param(m.params.size() - 1), utc); + send("CAP END"); + } else if (cmd == "PRIVMSG" || cmd == "NOTICE") { + onPrivmsg(m, utc, cmd == "NOTICE"); + } else if (cmd == "JOIN") { + if (fromMe) { + int b = bufferFor(m.param(0), IrcBuffer::Type::Channel); + buffers_[b].joined = true; + info(b, "Joined " + m.param(0), utc); + } + } else if (cmd == "PART") { + int b = findBuffer(m.param(0)); + if (fromMe && b > 0) { + buffers_[b].joined = false; + info(b, "Left " + m.param(0), utc); + } + } else if (cmd == "KICK") { + int b = findBuffer(m.param(0)); + if (lower(m.param(1)) == lower(nick_) && b > 0) { + buffers_[b].joined = false; + add(b, IrcLine::Kind::Notice, m.nick(), "kicked you: " + m.param(2), utc, true); + } + } else if (cmd == "NICK") { + if (fromMe) { + nick_ = m.param(0); + info(0, "You are now " + nick_, utc); + } else { + int b = findBuffer(m.nick()); + if (b > 0) { + buffers_[b].name = m.param(0); + info(b, m.nick() + " is now " + m.param(0), utc); + } + } + } else if (cmd == "TOPIC") { + int b = findBuffer(m.param(0)); + if (b > 0) { + buffers_[b].topic = m.param(1); + info(b, m.nick() + " set the topic: " + m.param(1), utc); + } + } else if (cmd == "332") { + int b = findBuffer(m.param(1)); + if (b > 0) { + buffers_[b].topic = m.param(2); + info(b, "Topic: " + m.param(2), utc); + } + } else if (cmd == "353") { + std::string channel = m.param(2); + int b = findBuffer(channel); + if (namesRequested_.count(lower(channel)) && b >= 0) info(b, "Names: " + m.param(3), utc); + } else if (cmd == "366") { + namesRequested_.erase(lower(m.param(1))); + } else if (cmd == "ERROR") { + info(0, "Server: " + m.param(0), utc); + } else if (cmd.size() == 3 && std::isdigit(static_cast(cmd[0])) && cmd != "333" && cmd != "353") { + info(0, join(m.params, 1), utc); // other numerics, minus our own nick + } +} + +void IrcSession::onPrivmsg(const IrcMessage& m, int64_t utc, bool notice) { + std::string target = m.param(0), text = m.param(1), from = m.nick(); + bool toMe = lower(target) == lower(nick_); + + // CTCP: ACTION is shown, VERSION answered, anything else ignored. + bool action = false; + if (!text.empty() && text[0] == kCtcp) { + std::string body = text.substr(1, text.size() >= 2 && text.back() == kCtcp ? text.size() - 2 : std::string::npos); + auto [verb, rest] = firstWord(body); + if (verb == "ACTION") { + action = true; + text = rest; + } else { + if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION roro9stack" + kCtcp); + return; + } + } + + int b; + if (notice && (from.empty() || from.find('.') != std::string::npos || !registered_ || target == "*")) { + b = 0; // server notices + } else if (toMe) { + b = notice ? (findBuffer(from) > 0 ? findBuffer(from) : 0) : bufferFor(from, IrcBuffer::Type::Query); + } else { + b = bufferFor(target, IrcBuffer::Type::Channel); + } + auto kind = notice ? IrcLine::Kind::Notice : action ? IrcLine::Kind::Action : IrcLine::Kind::Message; + bool mention = !notice && (toMe || mentionsMe(text)); + add(b, kind, from, text, utc, mention); +} + +void IrcSession::say(int b, const std::string& text, int64_t utc, bool action) { + const IrcBuffer& buf = buffers_[b]; + if (buf.type == IrcBuffer::Type::Server) { + info(b, "This is the server Buffer: /join #name an IRC channel, or /msg nick text", utc); + return; + } + std::string payload = action ? std::string(1, kCtcp) + "ACTION " + text + kCtcp : text; + send(IrcMessage::serialize("PRIVMSG", {buf.name, payload})); + add(b, action ? IrcLine::Kind::OwnAction : IrcLine::Kind::Own, nick_, text, utc); +} + +void IrcSession::input(int b, const std::string& text, int64_t utc) { + if (b < 0 || b >= bufferCount() || text.empty()) return; + if (text.size() >= 2 && text[0] == '/' && text[1] == '/') return say(b, text.substr(1), utc, false); + if (text[0] == '/') return command(b, text.substr(1), utc); + say(b, text, utc, false); +} + +void IrcSession::command(int b, const std::string& text, int64_t utc) { + auto [verb, rest] = firstWord(text); + verb = lower(verb); + const IrcBuffer& buf = buffers_[b]; + bool inChannel = buf.type == IrcBuffer::Type::Channel; + + if (verb == "join") { + auto [channel, key] = firstWord(rest); + if (channel.empty()) return info(b, "Usage: /join #channel", utc); + if (!isChannel(channel)) channel = "#" + channel; + send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key})); + } else if (verb == "part") { + std::string channel = inChannel ? buf.name : ""; + std::string message = rest; + if (isChannel(rest)) std::tie(channel, message) = firstWord(rest); + if (channel.empty()) return info(b, "Usage: /part #channel", utc); + send(message.empty() ? IrcMessage::serialize("PART", {channel}) : IrcMessage::serialize("PART", {channel, message})); + } else if (verb == "msg" || verb == "query") { + auto [who, message] = firstWord(rest); + if (who.empty()) return info(b, "Usage: /msg nick text", utc); + int target = bufferFor(who, isChannel(who) ? IrcBuffer::Type::Channel : IrcBuffer::Type::Query); + if (!message.empty()) say(target, message, utc, false); + } else if (verb == "me") { + say(b, rest, utc, true); + } else if (verb == "nick") { + if (rest.empty()) return info(b, "Usage: /nick newnick", utc); + send(IrcMessage::serialize("NICK", {rest})); + } else if (verb == "topic") { + if (!inChannel) return info(b, "Use /topic in an IRC channel", utc); + send(rest.empty() ? IrcMessage::serialize("TOPIC", {buf.name}) : IrcMessage::serialize("TOPIC", {buf.name, rest})); + } else if (verb == "names") { + std::string channel = rest.empty() ? (inChannel ? buf.name : "") : rest; + if (channel.empty()) return info(b, "Usage: /names #channel", utc); + namesRequested_.insert(lower(channel)); + send(IrcMessage::serialize("NAMES", {channel})); + } else if (verb == "quit") { + quit_ = true; + send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest})); + } else if (verb == "raw" || verb == "quote") { + if (!rest.empty()) send(rest); + } else { + info(b, "Unknown command /" + verb + " (try /join /part /msg /me /nick /topic /names /quit /raw)", utc); + } +} + +} // namespace roro diff --git a/lib/irc/src/irc_session.h b/lib/irc/src/irc_session.h new file mode 100644 index 0000000..9c76311 --- /dev/null +++ b/lib/irc/src/irc_session.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "irc_config.h" +#include "irc_message.h" + +namespace roro { + +struct IrcLine { + enum class Kind { Message, Action, Notice, Info, Own, OwnAction }; + int64_t utc; // -1 if the clock wasn't set + Kind kind; + std::string nick; + std::string text; +}; + +// One IRC conversation (see Buffer in CONTEXT.md). +struct IrcBuffer { + enum class Type { Server, Channel, Query }; + std::string name; + Type type; + std::deque lines; // the most recent kLinesPerBuffer; full history goes to Logs + int unread = 0; + bool mentioned = false; + bool joined = false; + std::string topic; +}; + +struct IrcLogEntry { + std::string buffer; + IrcLine line; +}; + +// What the session wants done: lines to send, Log entries, Notification texts. +struct IrcEffects { + std::vector send; + std::vector logs; + std::vector notifications; +}; + +// The IRC protocol, without any networking: registration (SASL PLAIN or NickServ), Buffers, +// unread counts, Mentions, the user's commands, and rejoining after a pause. The IRC Service +// feeds it received lines and carries out the effects. +class IrcSession { + public: + static constexpr size_t kLinesPerBuffer = 50; + static constexpr int kMaxBuffers = 12; + + explicit IrcSession(const IrcConfig& config); + + void connected(int64_t utc); + void disconnected(int64_t utc, const std::string& reason); + void receive(const std::string& raw, int64_t utc); + void input(int buffer, const std::string& text, int64_t utc); + + // The Buffer the user is looking at (-1: none); it neither counts unread nor notifies. + void setViewing(int buffer); + + bool registered() const { return registered_; } + bool quitRequested() const { return quit_; } + const std::string& nick() const { return nick_; } + + int bufferCount() const { return static_cast(buffers_.size()); } + const IrcBuffer& buffer(int i) const { return buffers_[i]; } + int totalUnread() const; + + IrcEffects takeEffects(); + + private: + void send(const std::string& line) { effects_.send.push_back(line); } + int findBuffer(const std::string& name) const; + int bufferFor(const std::string& name, IrcBuffer::Type type); // creates if needed and possible + void add(int buffer, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc, + bool countsAsMention = false); + void info(int buffer, const std::string& text, int64_t utc) { add(buffer, IrcLine::Kind::Info, "", text, utc); } + bool mentionsMe(const std::string& text) const; + void onPrivmsg(const IrcMessage& m, int64_t utc, bool notice); + void onWelcome(const IrcMessage& m, int64_t utc); + void command(int buffer, const std::string& text, int64_t utc); + void say(int buffer, const std::string& text, int64_t utc, bool action); + + const IrcConfig& config_; + std::vector buffers_; + std::string nick_; + bool registered_ = false; + bool quit_ = false; + int viewing_ = -1; + std::set namesRequested_; + std::vector rejoin_; // IRC channels to join again after a reconnect + IrcEffects effects_; +}; + +} // namespace roro diff --git a/lib/irc/src/reconnect_policy.h b/lib/irc/src/reconnect_policy.h new file mode 100644 index 0000000..b364313 --- /dev/null +++ b/lib/irc/src/reconnect_policy.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace roro { + +// Waits between IRC reconnection attempts: 5 s, 10 s, 30 s, 1 min, 2 min, then every 5 min. +class ReconnectPolicy { + public: + uint32_t nextDelayMs() { + static const uint32_t kDelays[] = {5000, 10000, 30000, 60000, 120000, 300000}; + const int n = sizeof(kDelays) / sizeof(kDelays[0]); + return kDelays[attempt_ < n ? attempt_++ : n - 1]; + } + void reset() { attempt_ = 0; } + + private: + int attempt_ = 0; +}; + +} // namespace roro diff --git a/test/test_irc_config/test_irc_config.cpp b/test/test_irc_config/test_irc_config.cpp new file mode 100644 index 0000000..142b6b2 --- /dev/null +++ b/test/test_irc_config/test_irc_config.cpp @@ -0,0 +1,74 @@ +#include + +#include "../memory_store.h" +#include "irc_config.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_defaults_point_at_libera_over_tls() { + MemoryStore store; + IrcConfig c(store); + c.load("roro_2fa4"); + TEST_ASSERT_EQUAL_STRING("irc.libera.chat", c.host.c_str()); + TEST_ASSERT_EQUAL(6697, c.port); + TEST_ASSERT_TRUE(c.tls); + TEST_ASSERT_FALSE(c.allowSelfSigned); + TEST_ASSERT_EQUAL_STRING("roro_2fa4", c.nick.c_str()); + TEST_ASSERT_TRUE(c.autojoin.empty()); +} + +void test_save_and_reload() { + MemoryStore store; + { + IrcConfig c(store); + c.load("x"); + c.nick = "clement"; + c.saslUser = "clement"; + c.saslPassword = "s3cret"; + c.autojoin = {"#roro", "#meshtastic"}; + TEST_ASSERT_TRUE(c.save().empty()); + } + IrcConfig again(store); + again.load("x"); + TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str()); + TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str()); + TEST_ASSERT_EQUAL(2, again.autojoin.size()); + TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].c_str()); +} + +void test_invalid_values_are_refused_with_a_reason() { + MemoryStore store; + IrcConfig c(store); + c.load("x"); + c.nick = "1abc"; // can't start with a digit + TEST_ASSERT_FALSE(c.save().empty()); + c.nick = "has space"; + TEST_ASSERT_FALSE(c.save().empty()); + c.nick = "ok_nick[1]"; + c.port = 0; + TEST_ASSERT_FALSE(c.save().empty()); + c.port = 6697; + c.host = ""; + TEST_ASSERT_FALSE(c.save().empty()); + c.host = "irc.libera.chat"; + c.autojoin = {"roro"}; // IRC channels start with # or & + TEST_ASSERT_FALSE(c.save().empty()); +} + +void test_autojoin_text_round_trip() { + TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size()); + TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); + TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_defaults_point_at_libera_over_tls); + RUN_TEST(test_save_and_reload); + RUN_TEST(test_invalid_values_are_refused_with_a_reason); + RUN_TEST(test_autojoin_text_round_trip); + return UNITY_END(); +} diff --git a/test/test_irc_message/test_irc_message.cpp b/test/test_irc_message/test_irc_message.cpp new file mode 100644 index 0000000..3415d0b --- /dev/null +++ b/test/test_irc_message/test_irc_message.cpp @@ -0,0 +1,74 @@ +#include + +#include "base64.h" +#include "irc_message.h" +#include "reconnect_policy.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_parse_full_message() { + auto m = IrcMessage::parse(":alice!a@host PRIVMSG #roro :hello there"); + TEST_ASSERT_EQUAL_STRING("alice!a@host", m.prefix.c_str()); + TEST_ASSERT_EQUAL_STRING("alice", m.nick().c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG", m.command.c_str()); + TEST_ASSERT_EQUAL(2, m.params.size()); + TEST_ASSERT_EQUAL_STRING("#roro", m.params[0].c_str()); + TEST_ASSERT_EQUAL_STRING("hello there", m.params[1].c_str()); +} + +void test_parse_without_prefix_and_with_tags() { + auto m = IrcMessage::parse("@time=2026-10-02T12:00:00Z PING :abc"); + TEST_ASSERT_EQUAL_STRING("", m.prefix.c_str()); + TEST_ASSERT_EQUAL_STRING("PING", m.command.c_str()); + TEST_ASSERT_EQUAL_STRING("abc", m.params[0].c_str()); +} + +void test_parse_strips_line_endings_and_handles_empty_trailing() { + auto m = IrcMessage::parse(":srv 332 me #roro :\r\n"); + TEST_ASSERT_EQUAL(3, m.params.size()); + TEST_ASSERT_EQUAL_STRING("", m.params[2].c_str()); +} + +void test_parse_middle_params_only() { + auto m = IrcMessage::parse(":srv MODE #roro +nt"); + TEST_ASSERT_EQUAL(2, m.params.size()); + TEST_ASSERT_EQUAL_STRING("+nt", m.params[1].c_str()); +} + +void test_serialize_adds_colon_only_when_needed() { + TEST_ASSERT_EQUAL_STRING("NICK roro", IrcMessage::serialize("NICK", {"roro"}).c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro :hello there", IrcMessage::serialize("PRIVMSG", {"#roro", "hello there"}).c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro ::)", IrcMessage::serialize("PRIVMSG", {"#roro", ":)"}).c_str()); + TEST_ASSERT_EQUAL_STRING("TOPIC #roro :", IrcMessage::serialize("TOPIC", {"#roro", ""}).c_str()); +} + +void test_base64() { + TEST_ASSERT_EQUAL_STRING("", base64Encode("").c_str()); + TEST_ASSERT_EQUAL_STRING("Zg==", base64Encode("f").c_str()); + TEST_ASSERT_EQUAL_STRING("Zm8=", base64Encode("fo").c_str()); + TEST_ASSERT_EQUAL_STRING("Zm9v", base64Encode("foo").c_str()); + TEST_ASSERT_EQUAL_STRING("dXNlcgB1c2VyAHBhc3M=", base64Encode(std::string("user\0user\0pass", 14)).c_str()); +} + +void test_reconnect_delays_grow_then_cap_and_reset() { + ReconnectPolicy p; + uint32_t expected[] = {5000, 10000, 30000, 60000, 120000, 300000, 300000}; + for (uint32_t e : expected) TEST_ASSERT_EQUAL_UINT32(e, p.nextDelayMs()); + p.reset(); + TEST_ASSERT_EQUAL_UINT32(5000, p.nextDelayMs()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_parse_full_message); + RUN_TEST(test_parse_without_prefix_and_with_tags); + RUN_TEST(test_parse_strips_line_endings_and_handles_empty_trailing); + RUN_TEST(test_parse_middle_params_only); + RUN_TEST(test_serialize_adds_colon_only_when_needed); + RUN_TEST(test_base64); + RUN_TEST(test_reconnect_delays_grow_then_cap_and_reset); + return UNITY_END(); +} diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp new file mode 100644 index 0000000..7a170fc --- /dev/null +++ b/test/test_irc_session/test_irc_session.cpp @@ -0,0 +1,345 @@ +#include + +#include +#include + +#include "../memory_store.h" +#include "irc_session.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct Fixture { + MemoryStore store; + IrcConfig config{store}; + std::unique_ptr session; + IrcEffects fx; + + explicit Fixture(bool sasl = false) { + config.load("roro"); + config.nick = "roro"; + config.autojoin = {"#roro"}; + if (sasl) { + config.saslUser = "acct"; + config.saslPassword = "pw"; + } + session.reset(new IrcSession(config)); + } + void recv(const std::string& line) { session->receive(line, 1000); } + IrcEffects& take() { + fx = session->takeEffects(); + return fx; + } + bool sent(const std::string& line) { + return std::find(fx.send.begin(), fx.send.end(), line) != fx.send.end(); + } + void registerNow() { + session->connected(0); + recv(":srv 001 roro :Welcome"); + take(); + } + int buffer(const std::string& name) { + for (int i = 0; i < session->bufferCount(); i++) + if (session->buffer(i).name == name) return i; + return -1; + } +}; + +void test_plain_registration_sends_nick_and_user() { + Fixture f; + f.session->connected(0); + f.take(); + TEST_ASSERT_TRUE(f.sent("NICK roro")); + TEST_ASSERT_TRUE(f.sent("USER roro 0 * roro9stack")); +} + +void test_welcome_registers_and_joins_autojoin_channels() { + Fixture f; + f.session->connected(0); + f.take(); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.session->registered()); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); +} + +void test_nickserv_identify_after_welcome() { + Fixture f; + f.config.nickservPassword = "pw"; + f.session.reset(new IrcSession(f.config)); + f.registerNow(); + f.session->connected(0); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw")); +} + +void test_sasl_plain_flow() { + Fixture f(true); + f.session->connected(0); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP REQ :sasl")); + f.recv(":srv CAP * ACK :sasl"); + f.take(); + TEST_ASSERT_TRUE(f.sent("AUTHENTICATE PLAIN")); + f.recv("AUTHENTICATE +"); + f.take(); + TEST_ASSERT_TRUE(f.sent("AUTHENTICATE YWNjdABhY2N0AHB3")); // acct\0acct\0pw + f.recv(":srv 903 roro :SASL authentication successful"); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP END")); +} + +void test_sasl_failure_still_ends_negotiation_and_says_so() { + Fixture f(true); + f.session->connected(0); + f.recv(":srv CAP * ACK :sasl"); + f.recv("AUTHENTICATE +"); + f.take(); + f.recv(":srv 904 roro :SASL authentication failed"); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP END")); + const auto& server = f.session->buffer(0); + TEST_ASSERT_TRUE(server.lines.back().text.find("SASL") != std::string::npos); +} + +void test_nick_in_use_during_registration_tries_another() { + Fixture f; + f.session->connected(0); + f.take(); + f.recv(":srv 433 * roro :Nickname is already in use"); + f.take(); + TEST_ASSERT_TRUE(f.sent("NICK roro_")); +} + +void test_ping_is_answered() { + Fixture f; + f.registerNow(); + f.recv("PING :tungsten.libera.chat"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PONG tungsten.libera.chat")); +} + +void test_own_join_creates_a_channel_buffer() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + TEST_ASSERT_TRUE(b > 0); + TEST_ASSERT_TRUE(f.session->buffer(b).joined); +} + +void test_others_joining_is_hidden() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + size_t before = f.session->buffer(f.buffer("#roro")).lines.size(); + f.recv(":bob!u@h JOIN #roro"); + f.recv(":bob!u@h PART #roro"); + f.recv(":bob!u@h QUIT :bye"); + TEST_ASSERT_EQUAL(before, f.session->buffer(f.buffer("#roro")).lines.size()); +} + +void test_channel_message_goes_to_its_buffer_and_is_logged() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.take(); + f.recv(":alice!u@h PRIVMSG #roro :hello all"); + auto& fx = f.take(); + const auto& b = f.session->buffer(f.buffer("#roro")); + TEST_ASSERT_EQUAL_STRING("alice", b.lines.back().nick.c_str()); + TEST_ASSERT_EQUAL_STRING("hello all", b.lines.back().text.c_str()); + TEST_ASSERT_EQUAL(1, fx.logs.size()); + TEST_ASSERT_EQUAL_STRING("#roro", fx.logs[0].buffer.c_str()); +} + +void test_unread_counts_unless_viewing_that_buffer() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + f.recv(":alice!u@h PRIVMSG #roro :one"); + f.recv(":alice!u@h PRIVMSG #roro :two"); + TEST_ASSERT_EQUAL(2, f.session->buffer(b).unread); + TEST_ASSERT_EQUAL(2, f.session->totalUnread()); + f.session->setViewing(b); + TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread); + f.recv(":alice!u@h PRIVMSG #roro :three"); + TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread); +} + +void test_mention_notifies_ordinary_traffic_does_not() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.take(); + f.recv(":alice!u@h PRIVMSG #roro :lunch?"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); + f.recv(":alice!u@h PRIVMSG #roro :Roro: ping"); + TEST_ASSERT_EQUAL(1, f.take().notifications.size()); + f.recv(":alice!u@h PRIVMSG #roro :rorojam is not you"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); +} + +void test_private_message_opens_a_query_buffer_and_notifies() { + Fixture f; + f.registerNow(); + f.recv(":alice!u@h PRIVMSG roro :psst"); + TEST_ASSERT_TRUE(f.buffer("alice") > 0); + TEST_ASSERT_EQUAL(1, f.take().notifications.size()); +} + +void test_no_notification_while_viewing_that_buffer() { + Fixture f; + f.registerNow(); + f.recv(":alice!u@h PRIVMSG roro :first"); + f.take(); + f.session->setViewing(f.buffer("alice")); + f.recv(":alice!u@h PRIVMSG roro :second"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); +} + +void test_action_and_ctcp_version() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.recv(":alice!u@h PRIVMSG #roro :\x01" "ACTION waves\x01"); + const auto& line = f.session->buffer(f.buffer("#roro")).lines.back(); + TEST_ASSERT_EQUAL(static_cast(IrcLine::Kind::Action), static_cast(line.kind)); + TEST_ASSERT_EQUAL_STRING("waves", line.text.c_str()); + f.take(); + f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01"); + f.take(); + TEST_ASSERT_TRUE(f.sent("NOTICE alice :\x01VERSION roro9stack\x01")); +} + +void test_topic_is_kept() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.recv(":srv 332 roro #roro :Welcome to roro"); + TEST_ASSERT_EQUAL_STRING("Welcome to roro", f.session->buffer(f.buffer("#roro")).topic.c_str()); +} + +void test_input_message_and_commands() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + f.take(); + f.session->input(b, "hello", 0); + f.session->input(b, "/me waves", 0); + f.session->input(b, "/topic New topic", 0); + f.session->input(b, "/join #other", 0); + f.session->input(b, "/part", 0); + f.session->input(b, "/nick roro2", 0); + f.session->input(b, "/names", 0); + f.session->input(b, "/raw MODE #roro +t", 0); + f.session->input(b, "//not a command", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro hello")); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :\x01" "ACTION waves\x01")); + TEST_ASSERT_TRUE(f.sent("TOPIC #roro :New topic")); + TEST_ASSERT_TRUE(f.sent("JOIN #other")); + TEST_ASSERT_TRUE(f.sent("PART #roro")); + TEST_ASSERT_TRUE(f.sent("NICK roro2")); + TEST_ASSERT_TRUE(f.sent("NAMES #roro")); + TEST_ASSERT_TRUE(f.sent("MODE #roro +t")); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :/not a command")); + // Own messages appear in the buffer + TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str()); +} + +void test_msg_opens_a_query_buffer() { + Fixture f; + f.registerNow(); + f.session->input(0, "/msg alice hi there", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG alice :hi there")); + TEST_ASSERT_TRUE(f.buffer("alice") > 0); +} + +void test_text_in_the_server_buffer_explains_instead_of_sending() { + Fixture f; + f.registerNow(); + f.session->input(0, "hello", 0); + TEST_ASSERT_TRUE(f.take().send.empty()); + TEST_ASSERT_TRUE(f.session->buffer(0).lines.back().text.find("/join") != std::string::npos); +} + +void test_quit_sends_and_is_remembered() { + Fixture f; + f.registerNow(); + f.session->input(0, "/quit bye", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("QUIT bye")); + TEST_ASSERT_TRUE(f.session->quitRequested()); +} + +void test_names_reply_only_shown_when_asked() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + size_t before = f.session->buffer(b).lines.size(); + f.recv(":srv 353 roro = #roro :roro alice bob"); + TEST_ASSERT_EQUAL(before, f.session->buffer(b).lines.size()); + f.session->input(b, "/names", 0); + f.recv(":srv 353 roro = #roro :roro alice bob"); + TEST_ASSERT_TRUE(f.session->buffer(b).lines.back().text.find("alice bob") != std::string::npos); +} + +void test_disconnect_marks_a_gap_and_reconnect_rejoins() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.session->input(1, "/join #extra", 0); + f.recv(":roro!u@h JOIN #extra"); + f.session->disconnected(5000, "paused for Wi-Fi monitoring"); + TEST_ASSERT_FALSE(f.session->registered()); + TEST_ASSERT_TRUE(f.session->buffer(f.buffer("#roro")).lines.back().text.find("paused") != std::string::npos); + f.take(); + f.session->connected(6000); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro,#extra")); +} + +void test_buffer_keeps_only_recent_lines() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + for (int i = 0; i < IrcSession::kLinesPerBuffer + 10; i++) f.recv(":a!u@h PRIVMSG #roro :x"); + TEST_ASSERT_EQUAL(IrcSession::kLinesPerBuffer, f.session->buffer(f.buffer("#roro")).lines.size()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_plain_registration_sends_nick_and_user); + RUN_TEST(test_welcome_registers_and_joins_autojoin_channels); + RUN_TEST(test_nickserv_identify_after_welcome); + RUN_TEST(test_sasl_plain_flow); + RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so); + RUN_TEST(test_nick_in_use_during_registration_tries_another); + RUN_TEST(test_ping_is_answered); + RUN_TEST(test_own_join_creates_a_channel_buffer); + RUN_TEST(test_others_joining_is_hidden); + RUN_TEST(test_channel_message_goes_to_its_buffer_and_is_logged); + RUN_TEST(test_unread_counts_unless_viewing_that_buffer); + RUN_TEST(test_mention_notifies_ordinary_traffic_does_not); + RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies); + RUN_TEST(test_no_notification_while_viewing_that_buffer); + RUN_TEST(test_action_and_ctcp_version); + RUN_TEST(test_topic_is_kept); + RUN_TEST(test_input_message_and_commands); + RUN_TEST(test_msg_opens_a_query_buffer); + RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending); + RUN_TEST(test_quit_sends_and_is_remembered); + RUN_TEST(test_names_reply_only_shown_when_asked); + RUN_TEST(test_disconnect_marks_a_gap_and_reconnect_rejoins); + RUN_TEST(test_buffer_keeps_only_recent_lines); + return UNITY_END(); +} From 74a341418e0b7ad8cc936aa378fd2c3c122924ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 15:10:03 +0200 Subject: [PATCH 5/9] M1 step 4b: IRC Service on the device; 8-bit frame buffer - IrcService: networking on its own task, TLS with the built-in CA bundle (or trust-on-first-use pinning when self-signed is allowed), plain TCP when TLS is off; connects only while Wi-Fi is Connected, marks pauses for Monitoring, reconnects with backoff, pings a quiet server, writes Logs, raises Notifications for Mentions - Session: forget /quit once disconnected (it was handled every loop); the server Buffer never counts as unread (MOTD showed as [4]) - Status Bar: unread count - Frame buffer 16 -> 8-bit colour (M1 Q46): min free heap with IRC on TLS went from 51 KB to 79 KB - Serial: irc start / say / dump Verified on the device against irc.libera.chat:6697: certificate checked, joined #roro9stack-test, sent a message, quit cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 3 + lib/irc/src/irc_session.cpp | 3 +- src/main.cpp | 27 +++ src/services/clock_service.h | 3 + src/services/irc_service.cpp | 211 +++++++++++++++++++++ src/services/irc_service.h | 87 +++++++++ src/ui/screen.cpp | 2 +- src/ui/screen.h | 2 +- src/ui/widgets.cpp | 1 + src/ui/widgets.h | 3 +- test/test_irc_session/test_irc_session.cpp | 19 ++ 11 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 src/services/irc_service.cpp create mode 100644 src/services/irc_service.h diff --git a/README.md b/README.md index 580ac2a..e1aad6f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ sudo usermod -aG dialout "$USER" | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | | `log ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | | `sd list` | Lists the files of each Storage Clean-up category | +| `irc start` | Starts the IRC Service (normally done by opening the IRC App) | +| `irc say ` | Types into a Buffer, commands included (`irc say 0 /join #test`) | +| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | `scripts/flash.sh` stops a running serial log first, since it would hold the port. diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index 501c84c..9d320c0 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -93,7 +93,7 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s effects_.logs.push_back({buf.name, line}); bool fromOthers = kind == IrcLine::Kind::Message || kind == IrcLine::Kind::Action || kind == IrcLine::Kind::Notice; - if (b != viewing_ && fromOthers) { + if (b != viewing_ && fromOthers && buf.type != IrcBuffer::Type::Server) { buf.unread++; if (mention) { buf.mentioned = true; @@ -114,6 +114,7 @@ void IrcSession::connected(int64_t) { void IrcSession::disconnected(int64_t utc, const std::string& reason) { registered_ = false; + quit_ = false; rejoin_.clear(); for (int i = 0; i < bufferCount(); i++) { if (buffers_[i].type == IrcBuffer::Type::Channel && buffers_[i].joined) rejoin_.push_back(buffers_[i].name); diff --git a/src/main.cpp b/src/main.cpp index 48bedf8..442d45a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,7 +10,9 @@ #include "key_mapper.h" #include "platform/nvs_store.h" #include "service_manager.h" +#include "platform/identity.h" #include "services/battery_service.h" +#include "services/irc_service.h" #include "services/clock_service.h" #include "services/power_service.h" #include "services/storage_service.h" @@ -37,6 +39,7 @@ static PowerService* power; static ClockService* clockService; static SavedNetworks* savedNetworks; static WifiService* wifi; +static IrcService* irc; static Notifier* notifier; static LauncherApp launcher; static AppManager* apps; @@ -66,6 +69,7 @@ static StatusInfo currentStatus() { s.sdPresent = storageState.present; s.sdLevel = storageState.level; s.compose = keyMapper.pendingCompose(); + s.unread = irc->totalUnread(); using WifiState = WifiController::State; switch (wifi->state()) { case WifiState::Connected: { @@ -97,6 +101,7 @@ void setup() { savedNetworks = new SavedNetworks(nvs); savedNetworks->load(); wifi = new WifiService(settings, *savedNetworks, *clockService); + irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus); notifier = new Notifier(bus, settings); notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; services.add(*power); @@ -104,6 +109,7 @@ void setup() { services.add(*battery); services.add(*storageService); services.add(*wifi); + services.add(*irc); apps = new AppManager(launcher); launcher.setManager(*apps); @@ -172,6 +178,27 @@ static void serialCommands() { storageService->requestListing(); listingWanted = true; } + if (line == "irc start") irc->connect(); + if (line.startsWith("irc say ")) { // irc say + String rest = line.substring(8); + int space = rest.indexOf(' '); + int b = rest.substring(0, space).toInt(); + std::string text = rest.substring(space + 1).c_str(); + irc->withSession([&](IrcSession& s) { s.input(b, text, clockService->utcNow()); }); + } + if (line == "irc dump") { + Serial.printf("irc: status %d, unread %d, heap %u min %u\n", (int)irc->status(), irc->totalUnread(), + ESP.getFreeHeap(), ESP.getMinFreeHeap()); + irc->withSession([](IrcSession& s) { + for (int i = 0; i < s.bufferCount(); i++) { + const auto& b = s.buffer(i); + Serial.printf("irc buffer %d %s unread %d%s\n", i, b.name.c_str(), b.unread, b.joined ? " joined" : ""); + size_t from = b.lines.size() > 6 ? b.lines.size() - 6 : 0; + for (size_t j = from; j < b.lines.size(); j++) + Serial.printf(" <%s> %s\n", b.lines[j].nick.c_str(), b.lines[j].text.c_str()); + } + }); + } 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(), diff --git a/src/services/clock_service.h b/src/services/clock_service.h index f830862..c613f43 100644 --- a/src/services/clock_service.h +++ b/src/services/clock_service.h @@ -23,6 +23,9 @@ class ClockService : public Service { bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } const ClockModel& model() const { return model_; } + // UTC seconds, or -1 if the clock isn't set. + int64_t utcNow() const { return model_.isSet() ? model_.utcNow(millis()) : -1; } + // Local "YYYY-MM-DD" once set; otherwise empty (Logs then go to undated files). std::string localDate() const { return model_.isSet() ? ClockModel::formatLocalDate(model_.utcNow(millis())) : ""; } // Days since 1970-01-01 (UTC), or -1 if the clock isn't set. diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp new file mode 100644 index 0000000..4ecd3a6 --- /dev/null +++ b/src/services/irc_service.cpp @@ -0,0 +1,211 @@ +#include "irc_service.h" + +#include + +#include + +#include "storage_paths.h" + +namespace roro { + +namespace { +constexpr uint32_t kQuietPingMs = 240000; // no traffic for 4 min: ping the server +constexpr uint32_t kDeadMs = 330000; // and give up if it still says nothing + +std::string hex(const uint8_t* bytes, size_t n) { + std::string out; + char buf[3]; + for (size_t i = 0; i < n; i++) { + std::snprintf(buf, sizeof(buf), "%02x", bytes[i]); + out += buf; + } + return out; +} +} // namespace + +IrcService::IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage, + ClockService& clock, EventBus& bus) + : config_(store), wifi_(wifi), storage_(storage), clock_(clock), bus_(bus) { + config_.load(defaultNick); + session_.reset(new IrcSession(config_)); +} + +void IrcService::start() { + if (task_) return; + lock_ = xSemaphoreCreateMutex(); + xTaskCreate(taskEntry, "irc", 8192, this, 1, &task_); +} + +void IrcService::connect() { + if (wanted_) return; + retryAtMs_ = millis(); + backoff_.reset(); + wanted_ = true; +} + +int IrcService::totalUnread() { + Lock l(lock_); + return session_->totalUnread(); +} + +std::string IrcService::saveConfig() { + std::string error = config_.save(); + if (error.empty() && !wanted_) { + Lock l(lock_); + session_.reset(new IrcSession(config_)); // a new server or nick starts a fresh session + } + return error; +} + +void IrcService::taskEntry(void* self) { static_cast(self)->loop(); } + +void IrcService::scheduleRetry(const std::string& why) { + uint32_t delay = backoff_.nextDelayMs(); + retryAtMs_ = millis() + delay; + status_ = Status::Retrying; + Lock l(lock_); + session_->disconnected(clock_.utcNow(), why + ", retrying in " + std::to_string(delay / 1000) + " s"); +} + +bool IrcService::open() { + status_ = Status::Connecting; + bool pinning = config_.tls && config_.allowSelfSigned; + conn_ = config_.tls ? static_cast(&tlsClient_) : &plainClient_; + if (config_.tls) { + tlsClient_.setTimeout(15); // seconds, for the handshake + if (pinning) tlsClient_.setInsecure(); + else tlsClient_.useBuiltinCACertBundle(); + } + if (!conn_->connect(config_.host.c_str(), config_.port)) return false; + + if (pinning) { + uint8_t sha[32]; + if (!tlsClient_.getFingerprintSHA256(sha)) { + tlsClient_.stop(); + return false; + } + std::string fingerprint = hex(sha, sizeof(sha)); + if (config_.pinnedSha256.empty()) { + config_.pinnedSha256 = fingerprint; // trust on first use + config_.save(); + } else if (config_.pinnedSha256 != fingerprint) { + tlsClient_.stop(); + Lock l(lock_); + session_->disconnected(clock_.utcNow(), "server certificate changed: not connecting"); + return false; + } + } + open_ = true; + partial_.clear(); + lastRxMs_ = millis(); + pingSent_ = false; + status_ = Status::Registering; + Lock l(lock_); + session_->connected(clock_.utcNow()); + return true; +} + +void IrcService::close(const std::string& reason) { + if (open_) conn_->stop(); + open_ = false; + if (!reason.empty()) { + Lock l(lock_); + session_->disconnected(clock_.utcNow(), reason); + } +} + +void IrcService::readLines() { + while (conn_->available()) { + char c = static_cast(conn_->read()); + if (c == '\n') { + lastRxMs_ = millis(); + pingSent_ = false; + Lock l(lock_); + session_->receive(partial_, clock_.utcNow()); + partial_.clear(); + } else if (c != '\r' && partial_.size() < 1024) { + partial_ += c; + } + } +} + +void IrcService::flushEffects() { + IrcEffects fx; + bool registered, quit; + { + Lock l(lock_); + fx = session_->takeEffects(); + registered = session_->registered(); + quit = session_->quitRequested(); + } + for (auto& line : fx.send) + if (open_) conn_->print((line + "\r\n").c_str()); + + std::string date = clock_.localDate(); + for (auto& entry : fx.logs) { + const IrcLine& l = entry.line; + std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) : "--:--"; + std::string text; + switch (l.kind) { + case IrcLine::Kind::Action: + case IrcLine::Kind::OwnAction: text = "* " + l.nick + " " + l.text; break; + case IrcLine::Kind::Notice: text = "-" + l.nick + "- " + l.text; break; + case IrcLine::Kind::Info: text = l.text; break; + default: text = "<" + l.nick + "> " + l.text; break; + } + storage_.appendLine(storage::dailyLogPath({"irc", config_.host, entry.buffer}, date), when + " " + text); + } + for (auto& n : fx.notifications) + bus_.publish(Event::withText(EventType::Notification, n.c_str(), static_cast(NotificationLevel::Message))); + + if (registered && status_ == Status::Registering) { + status_ = Status::Online; + backoff_.reset(); + } + if (quit && open_) { + delay(300); // let QUIT reach the server + close(""); + wanted_ = false; + status_ = Status::Stopped; + Lock l(lock_); + session_->disconnected(clock_.utcNow(), "disconnected"); + } +} + +void IrcService::loop() { + for (;;) { + uint32_t now = millis(); + bool wifiUp = wifi_.state() == WifiController::State::Connected; + bool monitoring = wifi_.state() == WifiController::State::Monitoring; + + if (!wanted_) { + if (open_) close("disconnected"); + status_ = Status::Stopped; + } else if (!wifiUp) { + if (open_) close(monitoring ? "paused for Wi-Fi monitoring" : "Wi-Fi lost"); + status_ = monitoring ? Status::Paused : Status::WaitingForWifi; + retryAtMs_ = now; // reconnect as soon as Wi-Fi is back + } else if (!open_) { + if (static_cast(now - retryAtMs_) >= 0) { + if (!open()) scheduleRetry("could not connect to " + config_.host); + } + } else if (!conn_->connected()) { + close(""); + scheduleRetry("connection lost"); + } else { + readLines(); + uint32_t quiet = millis() - lastRxMs_; + if (quiet > kDeadMs) { + close(""); + scheduleRetry("server stopped answering"); + } else if (quiet > kQuietPingMs && !pingSent_) { + conn_->print("PING :roro9stack\r\n"); + pingSent_ = true; + } + } + flushEffects(); + vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250)); + } +} + +} // namespace roro diff --git a/src/services/irc_service.h b/src/services/irc_service.h new file mode 100644 index 0000000..fd720ac --- /dev/null +++ b/src/services/irc_service.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "event_bus.h" +#include "irc_config.h" +#include "irc_session.h" +#include "reconnect_policy.h" +#include "service.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "services/wifi_service.h" + +namespace roro { + +// Keeps the IRC connection alive in the background once started (see IRC Service in CONTEXT.md): +// connects over TLS while Wi-Fi is Connected, pauses while Monitoring, reconnects with backoff, +// writes Logs and raises Notifications for Mentions. Networking runs on its own task; the +// session is shared with the IRC App under a lock. +class IrcService : public Service { + public: + enum class Status { Stopped, WaitingForWifi, Connecting, Registering, Online, Paused, Retrying }; + + IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage, + ClockService& clock, EventBus& bus); + const char* name() const override { return "irc"; } + void start() override; + + // Starts the session (the IRC App does this when opened); /quit stops it. + void connect(); + bool running() const { return wanted_; } + Status status() const { return status_; } + + // Read or act on the session while holding its lock: withSession([](IrcSession& s) { ... }). + template + auto withSession(F f) { + Lock l(lock_); + return f(*session_); + } + int totalUnread(); + + IrcConfig& config() { return config_; } + // Saves the config; takes effect at the next connection. + std::string saveConfig(); + + private: + struct Lock { + explicit Lock(SemaphoreHandle_t m) : m_(m) { xSemaphoreTake(m_, portMAX_DELAY); } + ~Lock() { xSemaphoreGive(m_); } + SemaphoreHandle_t m_; + }; + + static void taskEntry(void* self); + void loop(); + bool open(); + void close(const std::string& reason); + void readLines(); + void flushEffects(); + void scheduleRetry(const std::string& why); + + IrcConfig config_; + WifiService& wifi_; + StorageService& storage_; + ClockService& clock_; + EventBus& bus_; + std::unique_ptr session_; + SemaphoreHandle_t lock_ = nullptr; + TaskHandle_t task_ = nullptr; + NetworkClientSecure tlsClient_; + NetworkClient plainClient_; + NetworkClient* conn_ = &tlsClient_; // whichever the config asks for + ReconnectPolicy backoff_; + std::string partial_; + volatile bool wanted_ = false; + volatile Status status_ = Status::Stopped; + bool open_ = false; + uint32_t retryAtMs_ = 0; + uint32_t lastRxMs_ = 0; + bool pingSent_ = false; +}; + +} // namespace roro diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp index e45fd42..ef3e16a 100644 --- a/src/ui/screen.cpp +++ b/src/ui/screen.cpp @@ -5,7 +5,7 @@ namespace roro { bool Screen::begin() { - canvas_.setColorDepth(16); + canvas_.setColorDepth(8); // RGB332: saves 32 KB of RAM (M1 Q46); theme colours are converted canvas_.setPsram(false); return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; } diff --git a/src/ui/screen.h b/src/ui/screen.h index be9010d..558d401 100644 --- a/src/ui/screen.h +++ b/src/ui/screen.h @@ -9,7 +9,7 @@ namespace roro { // Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display. class Screen { public: - bool begin(); // allocates the frame buffer (~64 KB) + bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour) void render(AppManager& apps, const StatusInfo& status, const Toast* toast); Canvas& canvas() { return canvas_; } diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index ba8bccc..d5707f0 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -29,6 +29,7 @@ void statusBar(Canvas& c, const StatusInfo& info) { x -= c.textWidth(text.c_str()) + 6; }; right(info.clock, kText); + if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage); 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); diff --git a/src/ui/widgets.h b/src/ui/widgets.h index c0c3fbf..1a8a7a2 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -23,11 +23,12 @@ struct StatusInfo { char compose = 0; // pending Compose Key state enum class Wifi { None, Searching, Connected, Monitoring } wifi = Wifi::None; int wifiBars = 0; // 0..3 when Connected + int unread = 0; // unread messages across Services 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 && wifi == o.wifi && - wifiBars == o.wifiBars; + wifiBars == o.wifiBars && unread == o.unread; } bool operator!=(const StatusInfo& o) const { return !(*this == o); } }; diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index 7a170fc..ec18262 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -316,6 +316,23 @@ void test_buffer_keeps_only_recent_lines() { TEST_ASSERT_EQUAL(IrcSession::kLinesPerBuffer, f.session->buffer(f.buffer("#roro")).lines.size()); } +void test_quit_is_forgotten_once_disconnected() { + Fixture f; + f.registerNow(); + f.session->input(0, "/quit", 0); + f.session->disconnected(0, "disconnected"); + TEST_ASSERT_FALSE(f.session->quitRequested()); +} + +void test_server_buffer_never_counts_as_unread() { + Fixture f; + f.session->connected(0); + f.recv(":srv NOTICE * :*** Looking up your hostname..."); + f.recv(":srv 372 roro :- Message of the day"); + f.recv(":srv 001 roro :Welcome"); + TEST_ASSERT_EQUAL(0, f.session->totalUnread()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_plain_registration_sends_nick_and_user); @@ -341,5 +358,7 @@ int main() { RUN_TEST(test_names_reply_only_shown_when_asked); RUN_TEST(test_disconnect_marks_a_gap_and_reconnect_rejoins); RUN_TEST(test_buffer_keeps_only_recent_lines); + RUN_TEST(test_quit_is_forgotten_once_disconnected); + RUN_TEST(test_server_buffer_never_counts_as_unread); return UNITY_END(); } From 076baa77d19b8fa4b24200410c96357a897f01dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 15:14:59 +0200 Subject: [PATCH 6/9] M1 step 5: IRC App - Chat: header (Buffer n/N, unread elsewhere, topic or connection state), wrapped hh:mm lines (own in accent, Mentions green, info grey), input line; Tab cycles Buffers, Fn+Up/Down scrolls back, Enter sends, Back leaves while the IRC Service keeps running - /settings: server form (host, port, TLS, self-signed pinning, nick, SASL, NickServ, auto-join); Save & reconnect restarts the session - Opening the App starts the IRC Service; viewing a Buffer clears its unread count; IrcSession gains a revision counter for redraws Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/irc/src/irc_session.cpp | 3 + lib/irc/src/irc_session.h | 3 + src/apps/irc_app.cpp | 304 +++++++++++++++++++++ src/apps/irc_app.h | 55 ++++ src/main.cpp | 2 + src/services/irc_service.cpp | 19 +- src/services/irc_service.h | 3 +- test/test_irc_session/test_irc_session.cpp | 11 + 8 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 src/apps/irc_app.cpp create mode 100644 src/apps/irc_app.h diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index 9d320c0..b74f5c7 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -62,11 +62,13 @@ int IrcSession::bufferFor(const std::string& name, IrcBuffer::Type type) { if (i >= 0) return i; if (bufferCount() >= kMaxBuffers) return 0; // full: fall back to the server Buffer buffers_.push_back({name, type, {}, 0, false, false, ""}); + revision_++; return bufferCount() - 1; } void IrcSession::setViewing(int buffer) { viewing_ = buffer; + revision_++; if (buffer >= 0 && buffer < bufferCount()) { buffers_[buffer].unread = 0; buffers_[buffer].mentioned = false; @@ -87,6 +89,7 @@ bool IrcSession::mentionsMe(const std::string& text) const { void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc, bool mention) { IrcBuffer& buf = buffers_[b]; + revision_++; IrcLine line{utc, kind, nick, text}; buf.lines.push_back(line); if (buf.lines.size() > kLinesPerBuffer) buf.lines.pop_front(); diff --git a/lib/irc/src/irc_session.h b/lib/irc/src/irc_session.h index 9c76311..7d2aba6 100644 --- a/lib/irc/src/irc_session.h +++ b/lib/irc/src/irc_session.h @@ -68,6 +68,8 @@ class IrcSession { int bufferCount() const { return static_cast(buffers_.size()); } const IrcBuffer& buffer(int i) const { return buffers_[i]; } int totalUnread() const; + // Changes whenever a Buffer gains a line or changes, so a view knows when to redraw. + uint32_t revision() const { return revision_; } IrcEffects takeEffects(); @@ -93,6 +95,7 @@ class IrcSession { std::set namesRequested_; std::vector rejoin_; // IRC channels to join again after a reconnect IrcEffects effects_; + uint32_t revision_ = 0; }; } // namespace roro diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp new file mode 100644 index 0000000..7bee493 --- /dev/null +++ b/src/apps/irc_app.cpp @@ -0,0 +1,304 @@ +#include "irc_app.h" + +#include "ui/fonts.h" +#include "ui/widgets.h" + +namespace roro { + +using Status = IrcService::Status; + +namespace { + +const char* statusText(Status s) { + switch (s) { + case Status::Stopped: return "disconnected"; + case Status::WaitingForWifi: return "waiting for Wi-Fi"; + case Status::Connecting: return "connecting..."; + case Status::Registering: return "logging in..."; + case Status::Online: return ""; + case Status::Paused: return "paused (Wi-Fi monitoring)"; + case Status::Retrying: return "retrying soon"; + } + return ""; +} + +uint16_t lineColor(const IrcLine& l, bool mention) { + switch (l.kind) { + case IrcLine::Kind::Own: + case IrcLine::Kind::OwnAction: return theme::kAccent; + case IrcLine::Kind::Info: + case IrcLine::Kind::Notice: return theme::kMuted; + default: return mention ? theme::kMessage : theme::kText; + } +} + +std::string lowered(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +} // namespace + +void IrcApp::onEnter() { + page_ = Page::Chat; + editing_ = false; + irc_.connect(); + view(current_); +} + +void IrcApp::onExit() { + irc_.withSession([](IrcSession& s) { s.setViewing(-1); }); +} + +void IrcApp::view(int buffer) { + irc_.withSession([&](IrcSession& s) { + if (buffer < 0 || buffer >= s.bufferCount()) buffer = 0; + current_ = buffer; + s.setViewing(buffer); + }); + scroll_ = 0; + requestRedraw(); +} + +void IrcApp::warn(const std::string& text) { + bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(NotificationLevel::Warning))); +} + +void IrcApp::update(uint32_t) { + uint32_t revision = irc_.withSession([](IrcSession& s) { return s.revision(); }); + Status status = irc_.status(); + if (revision != seenRevision_ || status != seenStatus_) { + seenRevision_ = revision; + seenStatus_ = status; + requestRedraw(); + } +} + +bool IrcApp::onKey(const KeyEvent& e) { + requestRedraw(); + return page_ == Page::Chat ? onChatKey(e) : onSettingsKey(e); +} + +bool IrcApp::onChatKey(const KeyEvent& e) { + switch (e.key) { + case Key::Char: input_.insert(e.ch); break; + case Key::Delete: input_.backspace(); break; + case Key::Left: input_.left(); break; + case Key::Right: input_.right(); break; + case Key::Up: scroll_++; break; // older + case Key::Down: if (scroll_ > 0) scroll_--; break; + case Key::Tab: { + int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); }); + view((current_ + 1) % count); + break; + } + case Key::Select: { + std::string text = input_.text(); + if (text.empty()) break; + input_.setText(""); + scroll_ = 0; + if (lowered(text) == "/settings") { + page_ = Page::Settings; + fields_.setCount(kFields); + break; + } + if (!irc_.running()) irc_.connect(); // e.g. typing after /quit reconnects + irc_.withSession([&](IrcSession& s) { s.input(current_, text, clock_.utcNow()); }); + // /msg or /join may have opened a Buffer: follow a new private chat. + if (lowered(text).rfind("/msg ", 0) == 0) { + std::string who = text.substr(5, text.find(' ', 5) == std::string::npos ? std::string::npos : text.find(' ', 5) - 5); + irc_.withSession([&](IrcSession& s) { + for (int i = 0; i < s.bufferCount(); i++) + if (lowered(s.buffer(i).name) == lowered(who)) current_ = i; + s.setViewing(current_); + }); + } + break; + } + default: return false; // Back leaves the App; IRC keeps running + } + return true; +} + +std::string IrcApp::fieldLabel(int f) const { + static const char* labels[] = {"Server", "Port", "TLS", "Self-signed", "Nick", "SASL user", "SASL password", + "NickServ password", "Auto-join", "Save & reconnect"}; + return labels[f]; +} + +std::string IrcApp::fieldValue(int f) const { + const IrcConfig& c = irc_.config(); + auto secret = [](const std::string& s) { return s.empty() ? std::string("-") : std::string("(set)"); }; + switch (f) { + case kHost: return c.host; + case kPort: return std::to_string(c.port); + case kTls: return c.tls ? "On" : "Off"; + case kSelfSigned: return c.allowSelfSigned ? (c.pinnedSha256.empty() ? "Pin on first use" : "Pinned") : "Off"; + case kNick: return c.nick; + case kSaslUser: return c.saslUser.empty() ? "-" : c.saslUser; + case kSaslPass: return secret(c.saslPassword); + case kNickserv: return secret(c.nickservPassword); + case kAutojoin: return c.autojoin.empty() ? "-" : IrcConfig::formatChannels(c.autojoin); + default: return ""; + } +} + +void IrcApp::startEditing(int f) { + const IrcConfig& c = irc_.config(); + fieldEditor_ = LineEditor(f == kAutojoin ? 200 : 63); + switch (f) { + case kHost: fieldEditor_.setText(c.host); break; + case kPort: fieldEditor_.setText(std::to_string(c.port)); break; + case kNick: fieldEditor_.setText(c.nick); break; + case kSaslUser: fieldEditor_.setText(c.saslUser); break; + case kAutojoin: fieldEditor_.setText(IrcConfig::formatChannels(c.autojoin)); break; + default: break; // passwords start empty + } + editing_ = true; +} + +void IrcApp::finishEditing() { + IrcConfig& c = irc_.config(); + const std::string& v = fieldEditor_.text(); + switch (fields_.selected()) { + case kHost: c.host = v; break; + case kPort: c.port = std::atoi(v.c_str()); break; + case kNick: c.nick = v; break; + case kSaslUser: c.saslUser = v; break; + case kSaslPass: c.saslPassword = v; break; + case kNickserv: c.nickservPassword = v; break; + case kAutojoin: c.autojoin = IrcConfig::parseChannels(v); break; + } + editing_ = false; +} + +bool IrcApp::onSettingsKey(const KeyEvent& e) { + if (editing_) { + switch (e.key) { + case Key::Char: fieldEditor_.insert(e.ch); break; + case Key::Delete: fieldEditor_.backspace(); break; + case Key::Left: fieldEditor_.left(); break; + case Key::Right: fieldEditor_.right(); break; + case Key::Select: finishEditing(); break; + case Key::Back: editing_ = false; break; + default: break; + } + return true; + } + IrcConfig& c = irc_.config(); + switch (e.key) { + case Key::Up: fields_.up(); break; + case Key::Down: fields_.down(); break; + case Key::Back: + c.load(c.nick); // discard unsaved edits + page_ = Page::Chat; + break; + case Key::Select: + switch (fields_.selected()) { + case kTls: c.tls = !c.tls; break; + case kSelfSigned: + c.allowSelfSigned = !c.allowSelfSigned; + c.pinnedSha256.clear(); + break; + case kSave: { + std::string error = irc_.saveConfig(); + if (!error.empty()) { + warn(error); + break; + } + page_ = Page::Chat; + view(0); + break; + } + default: startEditing(fields_.selected()); break; + } + break; + default: break; + } + return true; +} + +void IrcApp::draw(Canvas& c) { + if (page_ == Page::Chat) drawChat(c); + else drawSettings(c); +} + +void IrcApp::drawChat(Canvas& c) { + const auto& area = theme::kContent; + const int inputH = theme::kLineHeight + 4; + const int headerH = 11; + const theme::Rect messages{area.x, area.y + headerH, area.w, area.h - headerH - inputH - 1}; + + std::vector> lines; // wrapped, oldest first + std::string header; + irc_.withSession([&](IrcSession& s) { + if (current_ >= s.bufferCount()) current_ = 0; + const IrcBuffer& b = s.buffer(current_); + header = b.name + " " + std::to_string(current_ + 1) + "/" + std::to_string(s.bufferCount()); + int others = s.totalUnread() - b.unread; + if (others > 0) header += " [" + std::to_string(others) + "]"; + std::string state = statusText(irc_.status()); + header += " " + (state.empty() ? b.topic : state); + + auto measure = widgets::bodyMeasure(c); + std::string me = lowered(s.nick()); + for (const IrcLine& l : b.lines) { + std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) + " " : ""; + std::string text; + switch (l.kind) { + case IrcLine::Kind::Action: + case IrcLine::Kind::OwnAction: text = "* " + l.nick + " " + l.text; break; + case IrcLine::Kind::Notice: text = "-" + l.nick + "- " + l.text; break; + case IrcLine::Kind::Info: text = l.text; break; + default: text = "<" + l.nick + "> " + l.text; break; + } + bool mention = l.kind == IrcLine::Kind::Message && lowered(l.text).find(me) != std::string::npos; + for (auto& w : wrapText(when + text, messages.w - 8, measure)) lines.push_back({w, lineColor(l, mention)}); + } + }); + + // Header + c.setFont(&fonts::small); + c.setTextColor(theme::kAccent); + c.setClipRect(area.x, area.y, area.w, headerH); + c.drawString(header.c_str(), 3, area.y + 2); + c.clearClipRect(); + + // Messages, newest at the bottom, scrolled back by scroll_ lines. + int rows = messages.h / theme::kLineHeight; + int total = static_cast(lines.size()); + if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0; + int first = total - rows - scroll_; + if (first < 0) first = 0; + c.setFont(&fonts::body); + c.setClipRect(messages.x, messages.y, messages.w, messages.h); + for (int r = 0; r < rows && first + r < total; r++) { + c.setTextColor(lines[first + r].second); + c.drawString(lines[first + r].first.c_str(), 4, messages.y + r * theme::kLineHeight + 1); + } + c.clearClipRect(); + if (scroll_ > 0) { + c.setFont(&fonts::small); + c.setTextColor(theme::kWarning); + c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 30, messages.y + 1); + } + + widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); +} + +void IrcApp::drawSettings(Canvas& c) { + const auto& area = theme::kContent; + if (editing_) { + c.setFont(&fonts::body); + c.setTextColor(theme::kMuted); + c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4); + widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0}); + c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro #meshtastic" : "Enter: OK `: cancel", 4, + area.y + 44); + return; + } + widgets::list( + c, fields_, area, [this](int i) { return fieldLabel(i); }, [this](int i) { return fieldValue(i); }); +} + +} // namespace roro diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h new file mode 100644 index 0000000..d711529 --- /dev/null +++ b/src/apps/irc_app.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "app.h" +#include "event_bus.h" +#include "line_editor.h" +#include "list_model.h" +#include "services/irc_service.h" +#include "ui/theme.h" + +namespace roro { + +// IRC: one Buffer at a time with an input line; /settings opens the server form. Opening the App +// starts the IRC Service, which keeps running after the App is left. +class IrcApp : public App { + public: + IrcApp(IrcService& irc, ClockService& clock, EventBus& bus) : irc_(irc), clock_(clock), bus_(bus) {} + void onEnter() override; + void onExit() override; + bool onKey(const KeyEvent& e) override; + void update(uint32_t nowMs) override; + bool textEntryActive() const override { return page_ == Page::Chat || editing_; } + void draw(Canvas& c) override; + + private: + enum class Page { Chat, Settings }; + enum Field { kHost, kPort, kTls, kSelfSigned, kNick, kSaslUser, kSaslPass, kNickserv, kAutojoin, kSave, kFields }; + + bool onChatKey(const KeyEvent& e); + bool onSettingsKey(const KeyEvent& e); + void view(int buffer); + void drawChat(Canvas& c); + void drawSettings(Canvas& c); + std::string fieldLabel(int f) const; + std::string fieldValue(int f) const; + void startEditing(int f); + void finishEditing(); + void warn(const std::string& text); + + IrcService& irc_; + ClockService& clock_; + EventBus& bus_; + Page page_ = Page::Chat; + int current_ = 0; + int scroll_ = 0; // wrapped lines scrolled back from the bottom + uint32_t seenRevision_ = 0; + IrcService::Status seenStatus_ = IrcService::Status::Stopped; + LineEditor input_{400}; + ListModel fields_{theme::kContent.h / theme::kLineHeight}; + bool editing_ = false; + LineEditor fieldEditor_{63}; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 442d45a..d49b617 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "app_manager.h" #include "apps/demo_app.h" +#include "apps/irc_app.h" #include "apps/launcher_app.h" #include "apps/settings_app.h" #include "apps/setup_app.h" @@ -113,6 +114,7 @@ void setup() { apps = new AppManager(launcher); launcher.setManager(*apps); + apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); apps->registerApp({"settings", "Settings", false, new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp index 4ecd3a6..44c9da8 100644 --- a/src/services/irc_service.cpp +++ b/src/services/irc_service.cpp @@ -50,11 +50,14 @@ int IrcService::totalUnread() { std::string IrcService::saveConfig() { std::string error = config_.save(); - if (error.empty() && !wanted_) { + if (!error.empty()) return error; + if (wanted_) { + restart_ = true; // the task reconnects with a fresh session + } else { Lock l(lock_); session_.reset(new IrcSession(config_)); // a new server or nick starts a fresh session } - return error; + return ""; } void IrcService::taskEntry(void* self) { static_cast(self)->loop(); } @@ -178,6 +181,18 @@ void IrcService::loop() { bool wifiUp = wifi_.state() == WifiController::State::Connected; bool monitoring = wifi_.state() == WifiController::State::Monitoring; + if (restart_) { + restart_ = false; + if (open_) { + conn_->print("QUIT :reconfiguring\r\n"); + vTaskDelay(pdMS_TO_TICKS(300)); + close(""); + } + Lock l(lock_); + session_.reset(new IrcSession(config_)); + retryAtMs_ = now; + backoff_.reset(); + } if (!wanted_) { if (open_) close("disconnected"); status_ = Status::Stopped; diff --git a/src/services/irc_service.h b/src/services/irc_service.h index fd720ac..a65a224 100644 --- a/src/services/irc_service.h +++ b/src/services/irc_service.h @@ -45,7 +45,7 @@ class IrcService : public Service { int totalUnread(); IrcConfig& config() { return config_; } - // Saves the config; takes effect at the next connection. + // Saves the config and, if running, reconnects with it (a fresh session). std::string saveConfig(); private: @@ -77,6 +77,7 @@ class IrcService : public Service { ReconnectPolicy backoff_; std::string partial_; volatile bool wanted_ = false; + volatile bool restart_ = false; volatile Status status_ = Status::Stopped; bool open_ = false; uint32_t retryAtMs_ = 0; diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index ec18262..fcdbd7b 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -333,6 +333,16 @@ void test_server_buffer_never_counts_as_unread() { TEST_ASSERT_EQUAL(0, f.session->totalUnread()); } +void test_revision_changes_when_something_visible_changes() { + Fixture f; + f.registerNow(); + uint32_t r = f.session->revision(); + f.recv("PING :x"); // nothing visible + TEST_ASSERT_EQUAL_UINT32(r, f.session->revision()); + f.recv(":roro!u@h JOIN #roro"); + TEST_ASSERT_TRUE(f.session->revision() != r); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_plain_registration_sends_nick_and_user); @@ -360,5 +370,6 @@ int main() { RUN_TEST(test_buffer_keeps_only_recent_lines); RUN_TEST(test_quit_is_forgotten_once_disconnected); RUN_TEST(test_server_buffer_never_counts_as_unread); + RUN_TEST(test_revision_changes_when_something_visible_changes); return UNITY_END(); } From 4cd6a63498db832ec8300f6b10c0d998b4c016cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 16:33:11 +0200 Subject: [PATCH 7/9] M1 step 6: Wi-Fi Tools from ordinary scans - lib/wifi (host-tested): channel occupancy (neighbour spill, signal weighting, quietest of 1/6/11) and a signal tracker (history, lost detection, click interval) - WifiService: scan results carry BSSID, channel and security; a scan can target one channel for quick tracker refreshes; endListScans() turns the radio back off when Wi-Fi is disabled - Wi-Fi Tools App: networks nearby, channel occupancy bars, signal tracker with clicks (m to mute) - M1 plan: Monitoring-mode views and captures deferred Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/M1.md | 6 +- lib/wifi/src/channel_occupancy.cpp | 35 ++++ lib/wifi/src/channel_occupancy.h | 21 ++ lib/wifi/src/signal_tracker.h | 52 +++++ src/apps/wifi_settings_page.cpp | 8 +- src/apps/wifi_tools_app.cpp | 215 +++++++++++++++++++++ src/apps/wifi_tools_app.h | 49 +++++ src/main.cpp | 2 + src/services/wifi_service.cpp | 46 ++++- src/services/wifi_service.h | 16 +- test/test_wifi_survey/test_wifi_survey.cpp | 78 ++++++++ 11 files changed, 512 insertions(+), 16 deletions(-) create mode 100644 lib/wifi/src/channel_occupancy.cpp create mode 100644 lib/wifi/src/channel_occupancy.h create mode 100644 lib/wifi/src/signal_tracker.h create mode 100644 src/apps/wifi_tools_app.cpp create mode 100644 src/apps/wifi_tools_app.h create mode 100644 test/test_wifi_survey/test_wifi_survey.cpp diff --git a/docs/milestones/M1.md b/docs/milestones/M1.md index 56dcaea..cde47cc 100644 --- a/docs/milestones/M1.md +++ b/docs/milestones/M1.md @@ -8,7 +8,7 @@ |---|---| | Q40 | Up to 8 **Saved Networks**, joined strongest-first. Added from a scan or as a hidden network. Open networks are allowed; enterprise (802.1X) is not. | | Q41 / Q48 | The Wi-Fi Service stays **Connected** whenever enabled and a Saved Network is in range. Settings has a Wi-Fi On/Off switch. **Monitoring** happens only while Wi-Fi Tools is open; the device reconnects on exit. | -| Q42 | Captures record **full frames**, from a fixed Wi-Fi channel or hopping, to `/captures/wifi/YYYYMMDD-HHMMSS.pcap`. Band-wide views hop across channels 1–13. | +| Q42 | *Revised 2026-10-02:* Wi-Fi Tools is built from ordinary scans only (access points, channel occupancy, signal tracker). Monitoring-mode views and captures are deferred, to be revisited later. | | Q43 | The signal tracker clicks faster as the signal gets stronger. On by default, mutable. | | Q44 | IRC shows one Buffer at a time; Tab cycles Buffers. Commands: `/join /part /msg /me /nick /topic /names /quit /raw`. Logs go to `/irc///YYYY-MM-DD.log`. | | Q45 | TLS verifies server certificates against the bundled certificate authorities. Per server, a self-signed certificate can be pinned on first use. | @@ -26,7 +26,7 @@ - Settings → Wi-Fi: On/Off, scan and add a network (with password), add a hidden network, forget a network. - The IRC App configures one server (host, port, TLS, nick, SASL or NickServ, auto-join IRC channels). It connects and keeps running after you leave the App. Mentions notify. - IRC reconnects after Wi-Fi loss or a server drop, and rejoins its IRC channels. Logs are written to the SD card (and stop past 90% usage). -- Wi-Fi Tools: access-point list, channel occupancy, signal tracker, packet counters, probe requests (logged), and pcap capture that opens in Wireshark. +- Wi-Fi Tools: access-point list, channel occupancy with the quietest of channels 1/6/11, and a signal tracker that clicks faster as the signal gets stronger. None of it interrupts the connection or IRC. - Settings → Storage → Clean-up deletes IRC Logs, probe Logs and Wi-Fi Captures older than a chosen age, with a preview of the space freed. - Free heap stays above about 40 KB with Wi-Fi, TLS-connected IRC and the UI running. @@ -50,4 +50,4 @@ Then the TLS transport with the certificate bundle. 5. **IRC App:** Buffer view, input line, command parser (host-tested), server settings page. -6. **Wi-Fi Tools App:** the six views; a pcap writer (host-tested); channel hopper; clicker for the signal tracker. +6. **Wi-Fi Tools App:** access-point list, channel occupancy and signal tracker from scans (occupancy and tracker logic host-tested). Monitoring mode is deferred. diff --git a/lib/wifi/src/channel_occupancy.cpp b/lib/wifi/src/channel_occupancy.cpp new file mode 100644 index 0000000..89a6105 --- /dev/null +++ b/lib/wifi/src/channel_occupancy.cpp @@ -0,0 +1,35 @@ +#include "channel_occupancy.h" + +namespace roro { + +namespace { +// -90 dBm (barely there) -> 0.1, -40 dBm and stronger -> 1. +float weight(int rssi) { + float w = (rssi + 90) / 50.0f; + if (w < 0.1f) w = 0.1f; + if (w > 1.0f) w = 1.0f; + return w; +} +const float kSpill[] = {1.0f, 0.5f, 0.2f}; // same channel, one away, two away +} // namespace + +std::array channelLoad(const std::vector& sightings) { + std::array load{}; + for (auto& s : sightings) { + if (s.channel < 1 || s.channel > 13) continue; + for (int d = -2; d <= 2; d++) { + int ch = s.channel + d; + if (ch >= 1 && ch <= 13) load[ch] += weight(s.rssi) * kSpill[d < 0 ? -d : d]; + } + } + return load; +} + +int quietestChannel(const std::array& load) { + int best = 1; + for (int ch : {6, 11}) + if (load[ch] < load[best]) best = ch; + return best; +} + +} // namespace roro diff --git a/lib/wifi/src/channel_occupancy.h b/lib/wifi/src/channel_occupancy.h new file mode 100644 index 0000000..58b9e79 --- /dev/null +++ b/lib/wifi/src/channel_occupancy.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace roro { + +struct ChannelSighting { + int channel; + int rssi; +}; + +// How crowded each 2.4 GHz Wi-Fi channel (1..13) is, from the access points a scan saw. A 20 MHz +// network spills onto the channels up to two away, less the further it is; a strong signal +// counts more than a faint one. Index 0 is unused. +std::array channelLoad(const std::vector& sightings); + +// The least crowded of the non-overlapping channels 1, 6 and 11 (the lowest on a tie). +int quietestChannel(const std::array& load); + +} // namespace roro diff --git a/lib/wifi/src/signal_tracker.h b/lib/wifi/src/signal_tracker.h new file mode 100644 index 0000000..7322800 --- /dev/null +++ b/lib/wifi/src/signal_tracker.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// Follows one access point's signal over repeated scans, for finding where it is. +class SignalTracker { + public: + static constexpr int kNoSignal = -127; + static constexpr uint32_t kLostAfterMs = 5000; + static constexpr uint32_t kFastestClickMs = 60; + static constexpr uint32_t kSlowestClickMs = 1200; + + explicit SignalTracker(size_t historySize) : size_(historySize) {} + + void seen(int rssi, uint32_t nowMs) { + push(rssi); + latest_ = rssi; + lastSeenMs_ = nowMs; + everSeen_ = true; + } + void missed() { push(kNoSignal); } // a scan that didn't see it + + bool lost(uint32_t nowMs) const { return !everSeen_ || nowMs - lastSeenMs_ >= kLostAfterMs; } + int latest() const { return latest_; } + const std::deque& history() const { return history_; } + + // Time between clicks: fast when the signal is strong (close), slow when faint. + static uint32_t clickIntervalMs(int rssi) { + if (rssi >= -35) return kFastestClickMs; + if (rssi <= -95) return kSlowestClickMs; + float t = (rssi + 95) / 60.0f; // 0 at -95 dBm, 1 at -35 dBm + return static_cast(kSlowestClickMs - t * (kSlowestClickMs - kFastestClickMs)); + } + + private: + void push(int v) { + history_.push_back(v); + if (history_.size() > size_) history_.pop_front(); + } + + size_t size_; + std::deque history_; + int latest_ = kNoSignal; + uint32_t lastSeenMs_ = 0; + bool everSeen_ = false; +}; + +} // namespace roro diff --git a/src/apps/wifi_settings_page.cpp b/src/apps/wifi_settings_page.cpp index ec626d8..7e57ced 100644 --- a/src/apps/wifi_settings_page.cpp +++ b/src/apps/wifi_settings_page.cpp @@ -73,7 +73,9 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) { } case View::Scan: { - const auto& found = wifi_.listScan(); + std::vector found; + for (auto& n : wifi_.listScan()) + if (!n.ssid.empty()) found.push_back(n); if (wifi_.listScanDone()) scan_.setCount(static_cast(found.size())); switch (e.key) { case Key::Up: scan_.up(); break; @@ -165,7 +167,9 @@ void WifiSettingsPage::draw(Canvas& c) { widgets::textLines(c, {"Scanning..."}, 0, area); break; } - const auto& found = wifi_.listScan(); + std::vector found; + for (auto& n : wifi_.listScan()) + if (!n.ssid.empty()) found.push_back(n); if (scan_.count() != static_cast(found.size())) scan_.setCount(static_cast(found.size())); if (found.empty()) { widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area); diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp new file mode 100644 index 0000000..d03c000 --- /dev/null +++ b/src/apps/wifi_tools_app.cpp @@ -0,0 +1,215 @@ +#include "wifi_tools_app.h" + +#include + +#include + +#include "channel_occupancy.h" +#include "ui/fonts.h" +#include "ui/widgets.h" + +namespace roro { + +namespace { +const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"}; +constexpr uint32_t kListRescanMs = 4000; +constexpr uint32_t kTrackRescanMs = 400; +} // namespace + +void WifiToolsApp::onEnter() { + view_ = View::Menu; + menu_.setCount(3); +} + +void WifiToolsApp::onExit() { wifi_.endListScans(); } + +void WifiToolsApp::open(View v) { + view_ = v; + nextScanMs_ = 0; // scan right away + requestRedraw(); +} + +std::vector WifiToolsApp::sortedScan() const { + auto list = wifi_.listScan(); + std::sort(list.begin(), list.end(), [](const ScanEntry& a, const ScanEntry& b) { return a.rssi > b.rssi; }); + return list; +} + +bool WifiToolsApp::onKey(const KeyEvent& e) { + requestRedraw(); + switch (view_) { + case View::Menu: + switch (e.key) { + case Key::Up: menu_.up(); return true; + case Key::Down: menu_.down(); return true; + case Key::Select: + if (menu_.selected() == 2) { + // The tracker follows an access point picked from the list. + open(View::Networks); + } else { + open(menu_.selected() == 0 ? View::Networks : View::Channels); + } + return true; + default: return false; + } + case View::Networks: + switch (e.key) { + case Key::Up: networks_.up(); break; + case Key::Down: networks_.down(); break; + case Key::Back: view_ = View::Menu; break; + case Key::Select: { + auto list = sortedScan(); + if (networks_.selected() < 0 || networks_.selected() >= static_cast(list.size())) break; + const ScanEntry& ap = list[networks_.selected()]; + targetBssid_ = ap.bssid; + targetName_ = ap.ssid.empty() ? "(hidden network)" : ap.ssid; + targetChannel_ = ap.channel; + tracker_ = SignalTracker(110); + tracker_.seen(ap.rssi, millis()); + open(View::Tracker); + break; + } + default: break; + } + return true; + case View::Channels: + if (e.key == Key::Back) view_ = View::Menu; + return true; + case View::Tracker: + if (e.key == Key::Back) view_ = View::Networks; + if (e.key == Key::Char && (e.ch == 'm' || e.ch == 'M')) clicks_ = !clicks_; + return true; + } + return false; +} + +void WifiToolsApp::update(uint32_t nowMs) { + if (view_ == View::Menu) return; + + if (wifi_.listScanSeq() != seenSeq_) { + seenSeq_ = wifi_.listScanSeq(); + if (view_ == View::Tracker) { + bool found = false; + for (auto& ap : wifi_.listScan()) { + if (ap.bssid == targetBssid_) { + tracker_.seen(ap.rssi, nowMs); + targetChannel_ = ap.channel; + found = true; + } + } + if (!found) tracker_.missed(); + } else { + networks_.setCount(static_cast(wifi_.listScan().size())); + } + requestRedraw(); + } + + if (wifi_.listScanDone() && static_cast(nowMs - nextScanMs_) >= 0) { + bool tracking = view_ == View::Tracker; + // A lost access point may have moved channel: fall back to a full scan. + wifi_.startListScan(tracking && !tracker_.lost(nowMs) ? targetChannel_ : 0); + nextScanMs_ = nowMs + (tracking ? kTrackRescanMs : kListRescanMs); + } + + if (view_ == View::Tracker && clicks_ && !tracker_.lost(nowMs) && static_cast(nowMs - nextClickMs_) >= 0) { + M5Cardputer.Speaker.tone(2600, 8); + nextClickMs_ = nowMs + SignalTracker::clickIntervalMs(tracker_.latest()); + } +} + +void WifiToolsApp::draw(Canvas& c) { + switch (view_) { + case View::Menu: + widgets::list(c, menu_, theme::kContent, [](int i) { return std::string(kMenu[i]); }); + break; + case View::Networks: drawNetworks(c); break; + case View::Channels: drawChannels(c); break; + case View::Tracker: drawTracker(c); break; + } +} + +void WifiToolsApp::drawNetworks(Canvas& c) { + auto list = sortedScan(); + if (list.empty()) { + widgets::textLines(c, {wifi_.listScanDone() ? "No networks found yet." : "Scanning..."}, 0, theme::kContent); + return; + } + if (networks_.count() != static_cast(list.size())) networks_.setCount(static_cast(list.size())); + widgets::list( + c, networks_, theme::kContent, + [&](int i) { return list[i].ssid.empty() ? std::string("(hidden)") : list[i].ssid; }, + [&](int i) { + return "ch" + std::to_string(list[i].channel) + " " + std::to_string(list[i].rssi) + " " + list[i].security; + }); +} + +void WifiToolsApp::drawChannels(Canvas& c) { + const auto& area = theme::kContent; + std::vector sightings; + for (auto& ap : wifi_.listScan()) sightings.push_back({ap.channel, ap.rssi}); + auto load = channelLoad(sightings); + int best = quietestChannel(load); + float maxLoad = 1.0f; + for (int ch = 1; ch <= 13; ch++) maxLoad = std::max(maxLoad, load[ch]); + + c.setFont(&fonts::body); + c.setTextColor(theme::kText); + std::string summary = std::to_string(sightings.size()) + " networks. Quietest of 1/6/11: " + std::to_string(best); + c.drawString(summary.c_str(), 4, area.y + 2); + + const int baseY = area.y + area.h - 14, barMaxH = area.h - 34, slot = 17, x0 = 10; + c.setFont(&fonts::small); + for (int ch = 1; ch <= 13; ch++) { + int x = x0 + (ch - 1) * slot; + int h = static_cast(load[ch] / maxLoad * barMaxH); + uint16_t color = ch == best ? theme::kMessage : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted; + if (h > 0) c.fillRect(x, baseY - h, slot - 4, h, color); + c.setTextColor(ch == best ? theme::kMessage : theme::kText); + c.setTextDatum(top_center); + c.drawString(std::to_string(ch).c_str(), x + (slot - 4) / 2, baseY + 3); + c.setTextDatum(top_left); + } + c.drawFastHLine(x0 - 2, baseY, 13 * slot, theme::kMuted); +} + +void WifiToolsApp::drawTracker(Canvas& c) { + const auto& area = theme::kContent; + uint32_t now = millis(); + bool lost = tracker_.lost(now); + + c.setFont(&fonts::bold); + c.setTextColor(theme::kText); + c.drawString(targetName_.c_str(), 4, area.y + 2); + c.setFont(&fonts::small); + c.setTextColor(theme::kMuted); + std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " m: clicks " + (clicks_ ? "on" : "off"); + c.drawString(sub.c_str(), 4, area.y + 17); + + // Big reading + c.setFont(&fonts::bold); + c.setTextSize(2); + c.setTextColor(lost ? theme::kWarning : theme::kText); + std::string reading = lost ? "lost" : std::to_string(tracker_.latest()) + " dBm"; + c.drawString(reading.c_str(), 4, area.y + 30); + c.setTextSize(1); + + // Strength bar: -95 dBm empty, -35 dBm full + int strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60)); + int barW = area.w - 8; + c.drawRect(4, area.y + 60, barW, 8, theme::kMuted); + c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kMessage); + + // History, newest at the right + const int gy = area.y + 74, gh = area.h - 76; + const auto& h = tracker_.history(); + int n = static_cast(h.size()); + for (int i = 0; i < n; i++) { + int v = h[i]; + if (v == SignalTracker::kNoSignal) continue; + int bh = std::min(gh, std::max(1, (v + 95) * gh / 60)); + int x = area.w - 4 - (n - i) * 2; + c.fillRect(x, gy + gh - bh, 2, bh, theme::kAccent); + } +} + +} // namespace roro diff --git a/src/apps/wifi_tools_app.h b/src/apps/wifi_tools_app.h new file mode 100644 index 0000000..75586f4 --- /dev/null +++ b/src/apps/wifi_tools_app.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#include "app.h" +#include "list_model.h" +#include "services/wifi_service.h" +#include "signal_tracker.h" +#include "ui/theme.h" + +namespace roro { + +// Wi-Fi diagnostics from ordinary scans: nearby access points, how crowded each channel is, and +// a signal tracker for finding one access point. None of it interrupts the connection or IRC. +class WifiToolsApp : public App { + public: + explicit WifiToolsApp(WifiService& wifi) : wifi_(wifi) {} + void onEnter() override; + void onExit() override; + bool onKey(const KeyEvent& e) override; + void update(uint32_t nowMs) override; + void draw(Canvas& c) override; + + private: + enum class View { Menu, Networks, Channels, Tracker }; + + void open(View v); + void drawNetworks(Canvas& c); + void drawChannels(Canvas& c); + void drawTracker(Canvas& c); + std::vector sortedScan() const; + + WifiService& wifi_; + View view_ = View::Menu; + ListModel menu_{theme::kContent.h / theme::kLineHeight}; + ListModel networks_{theme::kContent.h / theme::kLineHeight}; + uint32_t seenSeq_ = 0; + uint32_t nextScanMs_ = 0; + + // Tracker + std::string targetBssid_, targetName_; + int targetChannel_ = 0; + SignalTracker tracker_{110}; + bool clicks_ = true; + uint32_t nextClickMs_ = 0; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index d49b617..8dc1b21 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "apps/irc_app.h" #include "apps/launcher_app.h" #include "apps/settings_app.h" +#include "apps/wifi_tools_app.h" #include "apps/setup_app.h" #include "event_bus.h" #include "key_mapper.h" @@ -115,6 +116,7 @@ void setup() { apps = new AppManager(launcher); launcher.setManager(*apps); apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); + apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi)}); apps->registerApp({"settings", "Settings", false, new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index b1b088b..161d324 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -11,7 +11,7 @@ int WifiService::rssi() const { return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0; } -void WifiService::startScan() { +void WifiService::startScan(int channel) { if (!radioInitialised_) { WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(false); // the controller decides when to reconnect @@ -19,15 +19,45 @@ void WifiService::startScan() { radioInitialised_ = true; } if (scanRunning_) return; - if (WiFi.scanNetworks(true /* async */, false /* no hidden */) == WIFI_SCAN_FAILED) return; + // Hidden networks are listed too (with an empty name); the controller skips them. + if (WiFi.scanNetworks(true /* async */, true /* hidden */, false, channel ? 120 : 300, channel) == WIFI_SCAN_FAILED) + return; scanRunning_ = true; } -void WifiService::startListScan() { +void WifiService::startListScan(int channel) { + if (scanRunning_) { + listScanPending_ = true; // results of the scan already running will do + return; + } listScanPending_ = true; - startScan(); + startScan(channel); } +void WifiService::endListScans() { + listScanPending_ = false; + if (controller_.state() == WifiController::State::Off && radioInitialised_ && !scanRunning_) { + WiFi.mode(WIFI_OFF); + radioInitialised_ = false; + } +} + +namespace { +const char* security(wifi_auth_mode_t auth) { + switch (auth) { + case WIFI_AUTH_OPEN: return "open"; + case WIFI_AUTH_WEP: return "WEP"; + case WIFI_AUTH_WPA_PSK: return "WPA"; + case WIFI_AUTH_WPA2_PSK: + case WIFI_AUTH_WPA_WPA2_PSK: return "WPA2"; + case WIFI_AUTH_WPA2_ENTERPRISE: return "WPA2-E"; + case WIFI_AUTH_WPA3_PSK: + case WIFI_AUTH_WPA2_WPA3_PSK: return "WPA3"; + default: return "?"; + } +} +} // namespace + void WifiService::finishScan(uint32_t nowMs) { int n = WiFi.scanComplete(); if (n == WIFI_SCAN_RUNNING) return; @@ -37,14 +67,16 @@ void WifiService::finishScan(uint32_t nowMs) { std::vector 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_auth_mode_t auth = WiFi.encryptionType(i); + entries.push_back({ssid, WiFi.BSSIDstr(i).c_str(), WiFi.channel(i), WiFi.RSSI(i), auth == WIFI_AUTH_OPEN, + security(auth)}); + if (!ssid.empty()) results.push_back({ssid, WiFi.RSSI(i)}); } WiFi.scanDelete(); if (listScanPending_) { listScan_ = entries; listScanPending_ = false; + listScanSeq_++; } apply(controller_.scanDone(results, nowMs)); } diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index a5d92bb..1bb7ec6 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -12,9 +12,12 @@ namespace roro { struct ScanEntry { - std::string ssid; + std::string ssid; // empty for a hidden network + std::string bssid; + int channel; int rssi; bool open; + std::string security; // "open", "WPA2", "WPA3"... }; // Owns the Wi-Fi radio: carries out the WifiController's decisions with the ESP32 Wi-Fi stack, @@ -31,10 +34,14 @@ class WifiService : public Service { 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(); + // Scans for a list of nearby networks (all channels, or just one for a quick refresh); results + // arrive in listScan() when listScanDone(), and listScanSeq() counts completed scans. + void startListScan(int channel = 0); bool listScanDone() const { return !listScanPending_; } const std::vector& listScan() const { return listScan_; } + uint32_t listScanSeq() const { return listScanSeq_; } + // Done with list scans: switch the radio back off if Wi-Fi itself is off. + void endListScans(); // A Saved Network was added: try it now rather than after the retry delay. void savedNetworksChanged() { controller_.retryNow(millis()); } @@ -44,7 +51,7 @@ class WifiService : public Service { private: void apply(const WifiController::Step& step); - void startScan(); + void startScan(int channel = 0); void finishScan(uint32_t nowMs); void startNtp(); @@ -55,6 +62,7 @@ class WifiService : public Service { bool scanRunning_ = false; bool listScanPending_ = false; std::vector listScan_; + uint32_t listScanSeq_ = 0; bool ntpWaiting_ = false; bool radioInitialised_ = false; }; diff --git a/test/test_wifi_survey/test_wifi_survey.cpp b/test/test_wifi_survey/test_wifi_survey.cpp new file mode 100644 index 0000000..9e1da82 --- /dev/null +++ b/test/test_wifi_survey/test_wifi_survey.cpp @@ -0,0 +1,78 @@ +#include + +#include "channel_occupancy.h" +#include "signal_tracker.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_empty_band_is_quiet_everywhere() { + auto load = channelLoad({}); + for (int ch = 1; ch <= 13; ch++) TEST_ASSERT_EQUAL_FLOAT(0, load[ch]); +} + +void test_an_access_point_also_loads_its_neighbours_less() { + auto load = channelLoad({{6, -50}}); + TEST_ASSERT_TRUE(load[6] > load[5]); + TEST_ASSERT_TRUE(load[5] > load[4]); + TEST_ASSERT_TRUE(load[4] > 0); + TEST_ASSERT_EQUAL_FLOAT(0, load[3]); + TEST_ASSERT_EQUAL_FLOAT(load[5], load[7]); +} + +void test_stronger_signals_weigh_more() { + auto strong = channelLoad({{6, -40}}); + auto weak = channelLoad({{6, -85}}); + TEST_ASSERT_TRUE(strong[6] > weak[6]); + TEST_ASSERT_TRUE(weak[6] > 0); +} + +void test_recommends_the_quietest_of_1_6_11() { + TEST_ASSERT_EQUAL(11, quietestChannel(channelLoad({{1, -50}, {6, -50}, {6, -60}}))); + TEST_ASSERT_EQUAL(1, quietestChannel(channelLoad({{11, -50}, {6, -50}}))); + TEST_ASSERT_EQUAL(1, quietestChannel(channelLoad({}))); // ties: the lowest +} + +void test_out_of_band_channels_are_ignored() { + auto load = channelLoad({{14, -40}, {0, -40}, {36, -40}}); + for (int ch = 1; ch <= 13; ch++) TEST_ASSERT_EQUAL_FLOAT(0, load[ch]); +} + +void test_tracker_keeps_recent_readings() { + SignalTracker t(4); + for (int r : {-80, -70, -60, -50, -40}) t.seen(r, 0); + TEST_ASSERT_EQUAL(4, t.history().size()); + TEST_ASSERT_EQUAL(-70, t.history().front()); + TEST_ASSERT_EQUAL(-40, t.latest()); +} + +void test_tracker_reports_lost_after_a_while_unseen() { + SignalTracker t(8); + t.seen(-60, 1000); + TEST_ASSERT_FALSE(t.lost(3000)); + TEST_ASSERT_TRUE(t.lost(1000 + SignalTracker::kLostAfterMs)); + t.missed(); + TEST_ASSERT_EQUAL(SignalTracker::kNoSignal, t.history().back()); +} + +void test_clicks_speed_up_as_the_signal_gets_stronger() { + TEST_ASSERT_TRUE(SignalTracker::clickIntervalMs(-40) < SignalTracker::clickIntervalMs(-60)); + TEST_ASSERT_TRUE(SignalTracker::clickIntervalMs(-60) < SignalTracker::clickIntervalMs(-85)); + TEST_ASSERT_EQUAL_UINT32(SignalTracker::kFastestClickMs, SignalTracker::clickIntervalMs(-20)); + TEST_ASSERT_EQUAL_UINT32(SignalTracker::kSlowestClickMs, SignalTracker::clickIntervalMs(-100)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_empty_band_is_quiet_everywhere); + RUN_TEST(test_an_access_point_also_loads_its_neighbours_less); + RUN_TEST(test_stronger_signals_weigh_more); + RUN_TEST(test_recommends_the_quietest_of_1_6_11); + RUN_TEST(test_out_of_band_channels_are_ignored); + RUN_TEST(test_tracker_keeps_recent_readings); + RUN_TEST(test_tracker_reports_lost_after_a_while_unseen); + RUN_TEST(test_clicks_speed_up_as_the_signal_gets_stronger); + return UNITY_END(); +} From dd1635250908c8370814d811c72e7e1c626e7a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 3 Oct 2026 18:42:24 +0200 Subject: [PATCH 8/9] IRC: edit server settings as a draft, applied under the service lock The IRC App edited the live config while the IRC task could be reading it to connect. The App now edits a copy; applyConfig() validates it and swaps it in under the lock, and the task connects from a snapshot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/irc/src/irc_config.h | 15 +++++++++ src/apps/irc_app.cpp | 13 +++---- src/apps/irc_app.h | 2 ++ src/services/irc_service.cpp | 43 +++++++++++++++--------- src/services/irc_service.h | 7 ++-- test/test_irc_config/test_irc_config.cpp | 14 ++++++++ 6 files changed, 69 insertions(+), 25 deletions(-) diff --git a/lib/irc/src/irc_config.h b/lib/irc/src/irc_config.h index 924218f..144e130 100644 --- a/lib/irc/src/irc_config.h +++ b/lib/irc/src/irc_config.h @@ -22,6 +22,21 @@ class IrcConfig { std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set std::vector autojoin; + // A copy is a draft the UI can edit freely; copySettingsFrom() applies one. + IrcConfig(const IrcConfig&) = default; + void copySettingsFrom(const IrcConfig& other) { + host = other.host; + port = other.port; + tls = other.tls; + allowSelfSigned = other.allowSelfSigned; + pinnedSha256 = other.pinnedSha256; + nick = other.nick; + saslUser = other.saslUser; + saslPassword = other.saslPassword; + nickservPassword = other.nickservPassword; + autojoin = other.autojoin; + } + void load(const std::string& defaultNick); std::string save(); // empty on success, otherwise why it was refused std::string validate() const; diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp index 7bee493..c36c8cf 100644 --- a/src/apps/irc_app.cpp +++ b/src/apps/irc_app.cpp @@ -98,6 +98,7 @@ bool IrcApp::onChatKey(const KeyEvent& e) { input_.setText(""); scroll_ = 0; if (lowered(text) == "/settings") { + draft_.reset(new IrcConfig(irc_.draftConfig())); page_ = Page::Settings; fields_.setCount(kFields); break; @@ -127,7 +128,7 @@ std::string IrcApp::fieldLabel(int f) const { } std::string IrcApp::fieldValue(int f) const { - const IrcConfig& c = irc_.config(); + const IrcConfig& c = *draft_; auto secret = [](const std::string& s) { return s.empty() ? std::string("-") : std::string("(set)"); }; switch (f) { case kHost: return c.host; @@ -144,7 +145,7 @@ std::string IrcApp::fieldValue(int f) const { } void IrcApp::startEditing(int f) { - const IrcConfig& c = irc_.config(); + const IrcConfig& c = *draft_; fieldEditor_ = LineEditor(f == kAutojoin ? 200 : 63); switch (f) { case kHost: fieldEditor_.setText(c.host); break; @@ -158,7 +159,7 @@ void IrcApp::startEditing(int f) { } void IrcApp::finishEditing() { - IrcConfig& c = irc_.config(); + IrcConfig& c = *draft_; const std::string& v = fieldEditor_.text(); switch (fields_.selected()) { case kHost: c.host = v; break; @@ -185,12 +186,12 @@ bool IrcApp::onSettingsKey(const KeyEvent& e) { } return true; } - IrcConfig& c = irc_.config(); + IrcConfig& c = *draft_; switch (e.key) { case Key::Up: fields_.up(); break; case Key::Down: fields_.down(); break; case Key::Back: - c.load(c.nick); // discard unsaved edits + draft_.reset(); // discard unsaved edits page_ = Page::Chat; break; case Key::Select: @@ -201,7 +202,7 @@ bool IrcApp::onSettingsKey(const KeyEvent& e) { c.pinnedSha256.clear(); break; case kSave: { - std::string error = irc_.saveConfig(); + std::string error = irc_.applyConfig(c); if (!error.empty()) { warn(error); break; diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index d711529..77e8215 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "app.h" @@ -50,6 +51,7 @@ class IrcApp : public App { ListModel fields_{theme::kContent.h / theme::kLineHeight}; bool editing_ = false; LineEditor fieldEditor_{63}; + std::unique_ptr draft_; // the settings being edited, applied on Save }; } // namespace roro diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp index 44c9da8..d69d23f 100644 --- a/src/services/irc_service.cpp +++ b/src/services/irc_service.cpp @@ -48,15 +48,19 @@ int IrcService::totalUnread() { return session_->totalUnread(); } -std::string IrcService::saveConfig() { - std::string error = config_.save(); +IrcConfig IrcService::draftConfig() { + Lock l(lock_); + return config_; +} + +std::string IrcService::applyConfig(const IrcConfig& draft) { + std::string error = draft.validate(); if (!error.empty()) return error; - if (wanted_) { - restart_ = true; // the task reconnects with a fresh session - } else { - Lock l(lock_); - session_.reset(new IrcSession(config_)); // a new server or nick starts a fresh session - } + Lock l(lock_); + config_.copySettingsFrom(draft); + config_.save(); + if (wanted_) restart_ = true; // the task reconnects with a fresh session + else session_.reset(new IrcSession(config_)); return ""; } @@ -72,14 +76,15 @@ void IrcService::scheduleRetry(const std::string& why) { bool IrcService::open() { status_ = Status::Connecting; - bool pinning = config_.tls && config_.allowSelfSigned; - conn_ = config_.tls ? static_cast(&tlsClient_) : &plainClient_; - if (config_.tls) { + IrcConfig settings = draftConfig(); // a snapshot: the UI may apply new settings meanwhile + bool pinning = settings.tls && settings.allowSelfSigned; + conn_ = settings.tls ? static_cast(&tlsClient_) : &plainClient_; + if (settings.tls) { tlsClient_.setTimeout(15); // seconds, for the handshake if (pinning) tlsClient_.setInsecure(); else tlsClient_.useBuiltinCACertBundle(); } - if (!conn_->connect(config_.host.c_str(), config_.port)) return false; + if (!conn_->connect(settings.host.c_str(), settings.port)) return false; if (pinning) { uint8_t sha[32]; @@ -88,10 +93,11 @@ bool IrcService::open() { return false; } std::string fingerprint = hex(sha, sizeof(sha)); - if (config_.pinnedSha256.empty()) { + if (settings.pinnedSha256.empty()) { + Lock l(lock_); config_.pinnedSha256 = fingerprint; // trust on first use config_.save(); - } else if (config_.pinnedSha256 != fingerprint) { + } else if (settings.pinnedSha256 != fingerprint) { tlsClient_.stop(); Lock l(lock_); session_->disconnected(clock_.utcNow(), "server certificate changed: not connecting"); @@ -145,6 +151,11 @@ void IrcService::flushEffects() { if (open_) conn_->print((line + "\r\n").c_str()); std::string date = clock_.localDate(); + std::string host; + { + Lock l(lock_); + host = config_.host; + } for (auto& entry : fx.logs) { const IrcLine& l = entry.line; std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) : "--:--"; @@ -156,7 +167,7 @@ void IrcService::flushEffects() { case IrcLine::Kind::Info: text = l.text; break; default: text = "<" + l.nick + "> " + l.text; break; } - storage_.appendLine(storage::dailyLogPath({"irc", config_.host, entry.buffer}, date), when + " " + text); + storage_.appendLine(storage::dailyLogPath({"irc", host, entry.buffer}, date), when + " " + text); } for (auto& n : fx.notifications) bus_.publish(Event::withText(EventType::Notification, n.c_str(), static_cast(NotificationLevel::Message))); @@ -202,7 +213,7 @@ void IrcService::loop() { retryAtMs_ = now; // reconnect as soon as Wi-Fi is back } else if (!open_) { if (static_cast(now - retryAtMs_) >= 0) { - if (!open()) scheduleRetry("could not connect to " + config_.host); + if (!open()) scheduleRetry("could not connect to " + draftConfig().host); } } else if (!conn_->connected()) { close(""); diff --git a/src/services/irc_service.h b/src/services/irc_service.h index a65a224..5db3e79 100644 --- a/src/services/irc_service.h +++ b/src/services/irc_service.h @@ -44,9 +44,10 @@ class IrcService : public Service { } int totalUnread(); - IrcConfig& config() { return config_; } - // Saves the config and, if running, reconnects with it (a fresh session). - std::string saveConfig(); + // A copy of the server settings to edit; applyConfig() validates, saves and, if running, + // reconnects with it (a fresh session). + IrcConfig draftConfig(); + std::string applyConfig(const IrcConfig& draft); private: struct Lock { diff --git a/test/test_irc_config/test_irc_config.cpp b/test/test_irc_config/test_irc_config.cpp index 142b6b2..5773032 100644 --- a/test/test_irc_config/test_irc_config.cpp +++ b/test/test_irc_config/test_irc_config.cpp @@ -64,11 +64,25 @@ void test_autojoin_text_round_trip() { TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str()); } +void test_copy_settings_from_a_draft() { + MemoryStore store; + IrcConfig live(store); + live.load("x"); + IrcConfig draft = live; + draft.host = "irc.example.org"; + draft.autojoin = {"#a"}; + TEST_ASSERT_EQUAL_STRING("irc.libera.chat", live.host.c_str()); // the draft is independent + live.copySettingsFrom(draft); + TEST_ASSERT_EQUAL_STRING("irc.example.org", live.host.c_str()); + TEST_ASSERT_EQUAL(1, live.autojoin.size()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_defaults_point_at_libera_over_tls); RUN_TEST(test_save_and_reload); RUN_TEST(test_invalid_values_are_refused_with_a_reason); RUN_TEST(test_autojoin_text_round_trip); + RUN_TEST(test_copy_settings_from_a_draft); return UNITY_END(); } From e7da9bd3887207636a7e06094af3ecf4b9706574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 3 Oct 2026 18:42:24 +0200 Subject: [PATCH 9/9] Close M1: record outcome and carried-over items Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/M1.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/milestones/M1.md b/docs/milestones/M1.md index cde47cc..3d468e8 100644 --- a/docs/milestones/M1.md +++ b/docs/milestones/M1.md @@ -1,5 +1,22 @@ # M1 — Wi-Fi Service, Wi-Fi Tools, IRC +**Status:** done on 2026-10-03, tagged `v0.2.0`. + +## Outcome + +The "Done when" items below work on the device, against irc.libera.chat over TLS, with the following changes. + +- **Changed:** + - **Wi-Fi Tools is built from ordinary scans only:** networks nearby, channel occupancy and a signal tracker. Monitoring-mode views and captures are deferred (revised Q42). The Monitoring mode stays in the Wi-Fi Service and the IRC pause logic, unused for now. + - **The frame buffer is 8-bit colour** (Q46), applied as soon as IRC on TLS measured a 51 KB low. +- **Not done, carried over:** + - **Probe-request logs and Wi-Fi captures** are still listed as Storage Clean-up categories, but nothing writes to them yet. + - **IRC has no input history** (up-arrow recall). Each Buffer keeps only 50 lines in RAM; the full history is in the Logs. +- **Measured on the device:** + - With Wi-Fi and IRC on TLS: about 92 KB free heap, about 77–81 KB at the lowest. The floor was 40 KB. + - 44% of the app flash used. + - 231 host tests. + **Goal:** the device joins your Wi-Fi by itself, syncs its clock, keeps an IRC session alive in the background, and gives passive Wi-Fi diagnostics with full-frame captures. ## Decisions (design round 2026-10-02)