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(); +}