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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 14:46:07 +02:00
co-authored by Claude Opus 5.5
parent d8fe676c65
commit 1a0baf4ac7
22 changed files with 1108 additions and 48 deletions
@@ -0,0 +1,111 @@
#include <unity.h>
#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();
}
+1
View File
@@ -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());
}
@@ -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<int>(SettingsMenu::Kind::Page), static_cast<int>(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();
}
@@ -0,0 +1,210 @@
#include <unity.h>
#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<int>(s), static_cast<int>(w.state()));
}
static void assertAction(Action expected, const WifiController::Step& step) {
TEST_ASSERT_EQUAL(static_cast<int>(expected), static_cast<int>(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();
}