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
+3 -1
View File
@@ -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 "";
}
}
+1 -1
View File
@@ -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) {}
+1
View File
@@ -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<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -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
};
+67
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#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<int>(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<SavedNetwork> networks_;
};
} // namespace roro
+99
View File
@@ -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<int32_t>(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<ScanResult>& 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
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<ScanResult>& 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