Merge M1: Wi-Fi Service, Logs and Clean-up, IRC, Wi-Fi Tools

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-03 18:42:24 +02:00
co-authored by Claude Opus 5.5
61 changed files with 4344 additions and 86 deletions
+7
View File
@@ -47,5 +47,12 @@ sudo usermod -aG dialout "$USER"
| `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press | | `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.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 <buffer> <text>` | 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. `scripts/flash.sh` stops a running serial log first, since it would hold the port.
+20 -3
View File
@@ -1,5 +1,22 @@
# M1 — Wi-Fi Service, Wi-Fi Tools, IRC # 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. **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) ## Decisions (design round 2026-10-02)
@@ -8,7 +25,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. | | 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. | | 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. | | 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/<network>/<buffer>/YYYY-MM-DD.log`. | | 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/<network>/<buffer>/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. | | Q45 | TLS verifies server certificates against the bundled certificate authorities. Per server, a self-signed certificate can be pinned on first use. |
@@ -26,7 +43,7 @@
- Settings → Wi-Fi: On/Off, scan and add a network (with password), add a hidden network, forget a network. - 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. - 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). - 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. - 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. - Free heap stays above about 40 KB with Wi-Fi, TLS-connected IRC and the UI running.
@@ -50,4 +67,4 @@
Then the TLS transport with the certificate bundle. Then the TLS transport with the certificate bundle.
5. **IRC App:** Buffer view, input line, command parser (host-tested), server settings page. 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.
+3 -1
View File
@@ -20,7 +20,8 @@ const RowDef kRows[] = {
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, {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::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"}, {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::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
default: return ""; 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. // values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu { class SettingsMenu {
public: 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 }; enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <string>
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<uint8_t>(in[i]) << 16) | (static_cast<uint8_t>(in[i + 1]) << 8) | static_cast<uint8_t>(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<uint8_t>(in[i]) << 16;
if (rest == 2) n |= static_cast<uint8_t>(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
+79
View File
@@ -0,0 +1,79 @@
#include "irc_config.h"
#include <cctype>
#include <cstring>
namespace roro {
namespace {
bool validNick(const std::string& n) {
if (n.empty() || n.size() > 30) return false;
if (std::isdigit(static_cast<unsigned char>(n[0])) || n[0] == '-') return false;
for (char c : n)
if (!std::isalnum(static_cast<unsigned char>(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<std::string> IrcConfig::parseChannels(const std::string& text) {
std::vector<std::string> 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<std::string>& channels) {
std::string out;
for (auto& c : channels) out += (out.empty() ? "" : " ") + c;
return out;
}
} // namespace roro
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <vector>
#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<std::string> 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;
static std::vector<std::string> parseChannels(const std::string& text);
static std::string formatChannels(const std::vector<std::string>& channels);
private:
KeyValueStore& store_;
};
} // namespace roro
+43
View File
@@ -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<std::string> 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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <initializer_list>
#include <string>
#include <vector>
namespace roro {
// One IRC protocol line: [@tags] [:prefix] COMMAND params... [:trailing] (tags are ignored).
struct IrcMessage {
std::string prefix;
std::string command;
std::vector<std::string> 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<std::string> params);
};
} // namespace roro
+322
View File
@@ -0,0 +1,322 @@
#include "irc_session.h"
#include <algorithm>
#include <cctype>
#include "base64.h"
namespace roro {
namespace {
const char kCtcp = '\x01';
std::string lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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<unsigned char>(c)) || std::string("[]\\`_^{|}-").find(c) != std::string::npos; }
std::string join(const std::vector<std::string>& 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<std::string, std::string> 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, ""});
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;
}
}
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];
revision_++;
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.type != IrcBuffer::Type::Server) {
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;
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);
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<std::string> 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<unsigned char>(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
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <deque>
#include <set>
#include <string>
#include <vector>
#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<IrcLine> 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<std::string> send;
std::vector<IrcLogEntry> logs;
std::vector<std::string> 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<int>(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();
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<IrcBuffer> buffers_;
std::string nick_;
bool registered_ = false;
bool quit_ = false;
int viewing_ = -1;
std::set<std::string> namesRequested_;
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
IrcEffects effects_;
uint32_t revision_ = 0;
};
} // namespace roro
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
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
+9
View File
@@ -34,6 +34,15 @@ std::string ClockModel::formatLocalTime(int64_t utcSeconds) {
return buf; return buf;
} }
std::string ClockModel::formatLocalDate(int64_t utcSeconds) {
time_t t = static_cast<time_t>(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) { std::string ClockModel::formatAge(int64_t seconds) {
char buf[24]; char buf[24];
if (seconds < 60) return "now"; if (seconds < 60) return "now";
+1
View File
@@ -21,6 +21,7 @@ class ClockModel {
static void applyTimezone(const char* posixTz); static void applyTimezone(const char* posixTz);
static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM" 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" static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago"
private: private:
+1
View File
@@ -28,6 +28,7 @@ const Definition kDefinitions[] = {
{"off_s", Kind::Int, 60, nullptr, 10, 3600}, {"off_s", Kind::Int, 60, nullptr, 10, 3600},
{"sound", Kind::Bool, 1, nullptr, 0, 1}, {"sound", Kind::Bool, 1, nullptr, 0, 1},
{"probe_mac_raw", 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), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
+1
View File
@@ -20,6 +20,7 @@ enum class Setting : uint8_t {
OffTimeoutS, // int, 10..3600, above DimTimeoutS OffTimeoutS, // int, 10..3600, above DimTimeoutS
Sound, // bool Sound, // bool
ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses 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 Count
}; };
+53
View File
@@ -0,0 +1,53 @@
#include "cleanup_plan.h"
#include <cstdio>
#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<StoredFile>& 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<double>(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
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<std::string> paths;
uint64_t bytes = 0;
static CleanupPlan make(const std::vector<StoredFile>& files, CleanupAge age, int todayDay);
};
std::string formatBytes(uint64_t bytes);
} // namespace roro
+67
View File
@@ -0,0 +1,67 @@
#include "storage_paths.h"
#include <cctype>
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<unsigned char>(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<unsigned char>(c) < 0x20;
out += forbidden ? '_' : c;
}
if (out.empty() || out == ".") return "_";
if (out == "..") return "_.";
return out;
}
std::string dailyLogPath(std::initializer_list<std::string> 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<int>(utcSeconds / 86400); }
} // namespace roro::storage
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include <initializer_list>
#include <string>
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<std::string> 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
+35
View File
@@ -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<float, 14> channelLoad(const std::vector<ChannelSighting>& sightings) {
std::array<float, 14> 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<float, 14>& load) {
int best = 1;
for (int ch : {6, 11})
if (load[ch] < load[best]) best = ch;
return best;
}
} // namespace roro
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <array>
#include <vector>
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<float, 14> channelLoad(const std::vector<ChannelSighting>& sightings);
// The least crowded of the non-overlapping channels 1, 6 and 11 (the lowest on a tie).
int quietestChannel(const std::array<float, 14>& load);
} // namespace roro
+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
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
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<int>& 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<uint32_t>(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<int> history_;
int latest_ = kNoSignal;
uint32_t lastSeenMs_ = 0;
bool everSeen_ = false;
};
} // 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
+1 -1
View File
@@ -7,7 +7,6 @@ default_envs = cardputer-adv
[env] [env]
extra_scripts = pre:scripts/version.py extra_scripts = pre:scripts/version.py
test_framework = unity test_framework = unity
lib_ldf_mode = deep+
[env:cardputer-adv] [env:cardputer-adv]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip 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). ; Host-side unit tests for pure logic (no hardware).
[env:native] [env:native]
platform = native platform = native
lib_ldf_mode = deep+
build_flags = -std=gnu++17 build_flags = -std=gnu++17
+305
View File
@@ -0,0 +1,305 @@
#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<char>(std::tolower(static_cast<unsigned char>(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<int32_t>(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") {
draft_.reset(new IrcConfig(irc_.draftConfig()));
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 = *draft_;
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 = *draft_;
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 = *draft_;
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 = *draft_;
switch (e.key) {
case Key::Up: fields_.up(); break;
case Key::Down: fields_.down(); break;
case Key::Back:
draft_.reset(); // 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_.applyConfig(c);
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<std::pair<std::string, uint16_t>> 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<int>(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
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <string>
#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};
std::unique_ptr<IrcConfig> draft_; // the settings being edited, applied on Save
};
} // namespace roro
+23 -44
View File
@@ -14,7 +14,6 @@ using Row = SettingsMenu::Row;
void SettingsApp::onEnter() { void SettingsApp::onEnter() {
page_ = Page::Menu; page_ = Page::Menu;
eraseDialog_.reset();
list_.setCount(menu_.count()); list_.setCount(menu_.count());
} }
@@ -28,8 +27,13 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Menu: return onMenuKey(e); case Page::Menu: return onMenuKey(e);
case Page::Text: return onTextKey(e); case Page::Text: return onTextKey(e);
case Page::Choice: return onChoiceKey(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::About: return onAboutKey(e);
case Page::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
return true;
} }
return false; return false;
} }
@@ -61,7 +65,19 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
break; break;
case Kind::Toggle: menu_.toggle(i); break; case Kind::Toggle: menu_.toggle(i); break;
case Kind::Slider: 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;
storagePage_.enter();
break;
case Row::Wifi:
page_ = Page::Wifi;
wifiPage_.enter();
break;
default: page_ = Page::About; break;
}
break;
} }
return true; return true;
} }
@@ -100,21 +116,6 @@ bool SettingsApp::onChoiceKey(const KeyEvent& e) {
return true; 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) { bool SettingsApp::onAboutKey(const KeyEvent& e) {
if (e.key == Key::Back) page_ = Page::Menu; if (e.key == Key::Back) page_ = Page::Menu;
if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo
@@ -123,31 +124,13 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
void SettingsApp::update(uint32_t nowMs) { void SettingsApp::update(uint32_t nowMs) {
// Live values on Storage and About. // 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; lastRefreshMs_ = nowMs;
requestRedraw(); requestRedraw();
} }
} }
std::vector<std::string> SettingsApp::storageLines() const {
auto s = d_.storage.state();
std::vector<std::string> 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<unsigned>(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<std::string> SettingsApp::aboutLines() const { std::vector<std::string> SettingsApp::aboutLines() const {
std::vector<std::string> lines; std::vector<std::string> lines;
char buf[64]; char buf[64];
@@ -197,13 +180,9 @@ void SettingsApp::draw(Canvas& c) {
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; }); widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
break; break;
} }
case Page::Storage: case Page::Storage: storagePage_.draw(c); break;
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::About: widgets::textLines(c, aboutLines(), 0, area); break; case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break;
} }
} }
+15 -6
View File
@@ -13,6 +13,8 @@
#include "services/battery_service.h" #include "services/battery_service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "apps/storage_page.h"
#include "apps/wifi_settings_page.h"
#include "settings_menu.h" #include "settings_menu.h"
#include "ui/theme.h" #include "ui/theme.h"
@@ -25,37 +27,44 @@ struct SettingsAppDeps {
BatteryService& battery; BatteryService& battery;
StorageService& storage; StorageService& storage;
ClockService& clock; ClockService& clock;
WifiService& wifi;
SavedNetworks& savedNetworks;
}; };
// Settings: every user-facing setting, plus the Storage and About pages. // Settings: every user-facing setting, plus the Storage and About pages.
class SettingsApp : public App { class SettingsApp : public App {
public: 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),
storagePage_(deps.storage, deps.clock, deps.bus) {}
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) 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; void draw(Canvas& c) override;
private: private:
enum class Page { Menu, Text, Choice, Storage, About }; enum class Page { Menu, Text, Choice, Storage, About, Wifi };
bool onMenuKey(const KeyEvent& e); bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e);
bool onChoiceKey(const KeyEvent& e); bool onChoiceKey(const KeyEvent& e);
bool onStorageKey(const KeyEvent& e);
bool onAboutKey(const KeyEvent& e); bool onAboutKey(const KeyEvent& e);
void warn(const std::string& text); void warn(const std::string& text);
std::vector<std::string> storageLines() const;
std::vector<std::string> aboutLines() const; std::vector<std::string> aboutLines() const;
SettingsAppDeps d_; SettingsAppDeps d_;
SettingsMenu menu_; SettingsMenu menu_;
WifiSettingsPage wifiPage_;
StoragePage storagePage_;
Page page_ = Page::Menu; Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel list_{theme::kContent.h / theme::kLineHeight};
ListModel choices_{theme::kContent.h / theme::kLineHeight}; ListModel choices_{theme::kContent.h / theme::kLineHeight};
LineEditor editor_{39}; LineEditor editor_{39};
std::unique_ptr<DialogModel> eraseDialog_;
int editingRow_ = 0; int editingRow_ = 0;
uint32_t lastRefreshMs_ = 0; uint32_t lastRefreshMs_ = 0;
}; };
+171
View File
@@ -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<int32_t>(NotificationLevel::Warning)));
}
uint64_t StoragePage::categoryBytes(int category) const {
uint64_t sum = 0;
if (category < static_cast<int>(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<CleanupAge>(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
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <memory>
#include <vector>
#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<std::vector<StoredFile>> listing_;
bool haveListing_ = false;
std::unique_ptr<DialogModel> dialog_;
CleanupPlan pending_;
};
} // namespace roro
+198
View File
@@ -0,0 +1,198 @@
#include "wifi_settings_page.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
namespace roro {
using State = WifiController::State;
void WifiSettingsPage::enter() {
view_ = View::Main;
forgetDialog_.reset();
refreshMain();
}
void WifiSettingsPage::refreshMain() { main_.setCount(kFixedRows + saved_.count()); }
void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
}
std::string WifiSettingsPage::statusText() const {
switch (wifi_.state()) {
case State::Off: return "Off";
case State::Scanning: return "Searching...";
case State::Waiting: return "No saved network in range";
case State::Connecting: return "Joining " + wifi_.ssid() + "...";
case State::Connected: return wifi_.ssid() + " (" + std::to_string(wifi_.rssi()) + " dBm)";
case State::Monitoring: return "Monitoring";
}
return "";
}
bool WifiSettingsPage::onKey(const KeyEvent& e) {
if (forgetDialog_) {
forgetDialog_->onKey(e);
if (forgetDialog_->result() == 1) {
saved_.forget(saved_.at(main_.selected() - kFixedRows).ssid);
refreshMain();
}
if (forgetDialog_->result() != DialogModel::kPending) forgetDialog_.reset();
return true;
}
switch (view_) {
case View::Main:
switch (e.key) {
case Key::Up: main_.up(); return true;
case Key::Down: main_.down(); return true;
case Key::Back: return false;
case Key::Left:
case Key::Right:
if (main_.selected() != kToggle) return true;
[[fallthrough]];
case Key::Select:
switch (main_.selected()) {
case kToggle: settings_.setBool(Setting::WifiEnabled, !settings_.getBool(Setting::WifiEnabled)); break;
case kStatus: break;
case kAddScanned:
wifi_.startListScan();
scan_.setCount(0);
view_ = View::Scan;
break;
case kAddHidden:
newHidden_ = true;
editor_ = LineEditor(32);
view_ = View::Ssid;
break;
default: forgetDialog_.reset(new DialogModel({"Cancel", "Forget"})); break;
}
return true;
default: return true;
}
case View::Scan: {
std::vector<ScanEntry> found;
for (auto& n : wifi_.listScan())
if (!n.ssid.empty()) found.push_back(n);
if (wifi_.listScanDone()) scan_.setCount(static_cast<int>(found.size()));
switch (e.key) {
case Key::Up: scan_.up(); break;
case Key::Down: scan_.down(); break;
case Key::Back: view_ = View::Main; break;
case Key::Select:
if (scan_.selected() < 0) break;
newSsid_ = found[scan_.selected()].ssid;
newHidden_ = false;
if (found[scan_.selected()].open) {
std::string error = saved_.add(newSsid_, "", false);
if (!error.empty()) warn(error);
wifi_.savedNetworksChanged();
enter();
} else {
editor_ = LineEditor(63);
view_ = View::Password;
}
break;
default: break;
}
return true;
}
case View::Ssid:
case View::Password:
switch (e.key) {
case Key::Char: editor_.insert(e.ch); break;
case Key::Delete: editor_.backspace(); break;
case Key::Left: editor_.left(); break;
case Key::Right: editor_.right(); break;
case Key::Back: enter(); break;
case Key::Select:
if (view_ == View::Ssid) {
newSsid_ = editor_.text();
editor_ = LineEditor(63);
view_ = View::Password;
} else {
std::string error = saved_.add(newSsid_, editor_.text(), newHidden_);
if (!error.empty()) {
warn(error);
} else {
warn("Saved " + newSsid_, NotificationLevel::Info);
wifi_.savedNetworksChanged();
enter();
}
}
break;
default: break;
}
return true;
}
return true;
}
void WifiSettingsPage::draw(Canvas& c) {
const auto& area = theme::kContent;
switch (view_) {
case View::Main:
widgets::list(
c, main_, area,
[this](int i) -> std::string {
switch (i) {
case kToggle: return "Wi-Fi";
case kStatus: return "Status";
case kAddScanned: return "Add a network";
case kAddHidden: return "Add a hidden network";
default: {
const auto& n = saved_.at(i - kFixedRows);
return " " + n.ssid + (n.hidden ? " (hidden)" : "");
}
}
},
[this](int i) -> std::string {
switch (i) {
case kToggle: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
case kStatus: return statusText();
case kAddScanned:
case kAddHidden: return ">";
default: return "";
}
});
if (forgetDialog_ && main_.selected() >= kFixedRows)
widgets::dialog(c, "Forget network?", saved_.at(main_.selected() - kFixedRows).ssid, *forgetDialog_);
break;
case View::Scan: {
if (!wifi_.listScanDone()) {
widgets::textLines(c, {"Scanning..."}, 0, area);
break;
}
std::vector<ScanEntry> found;
for (auto& n : wifi_.listScan())
if (!n.ssid.empty()) found.push_back(n);
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
if (found.empty()) {
widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area);
break;
}
widgets::list(
c, scan_, area, [&](int i) { return found[i].ssid + (found[i].open ? " (open)" : ""); },
[&](int i) { return std::to_string(found[i].rssi) + " dBm"; });
break;
}
case View::Ssid:
case View::Password: {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_;
c.drawString(title.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(view_ == View::Ssid ? "Enter: next `: cancel" : "Enter: save (empty = open) `: cancel", 4,
area.y + 44);
break;
}
}
}
} // namespace roro
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <memory>
#include <string>
#include "dialog_model.h"
#include "event_bus.h"
#include "key_event.h"
#include "line_editor.h"
#include "list_model.h"
#include "saved_networks.h"
#include "services/wifi_service.h"
#include "settings.h"
#include "ui/canvas.h"
#include "ui/theme.h"
namespace roro {
// Settings → Wi-Fi: the On/Off switch, current connection, Saved Networks, and adding networks
// from a scan or by name (hidden). Owned by the Settings App.
class WifiSettingsPage {
public:
WifiSettingsPage(Settings& settings, SavedNetworks& saved, WifiService& wifi, EventBus& bus)
: settings_(settings), saved_(saved), wifi_(wifi), bus_(bus) {}
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password; }
bool live() const { return view_ == View::Main || view_ == View::Scan; } // redraw periodically
void draw(Canvas& c);
private:
enum class View { Main, Scan, Ssid, Password };
enum Fixed { kToggle, kStatus, kAddScanned, kAddHidden, kFixedRows };
void refreshMain();
void warn(const std::string& text, NotificationLevel level = NotificationLevel::Warning);
std::string statusText() const;
Settings& settings_;
SavedNetworks& saved_;
WifiService& wifi_;
EventBus& bus_;
View view_ = View::Main;
ListModel main_{theme::kContent.h / theme::kLineHeight};
ListModel scan_{theme::kContent.h / theme::kLineHeight};
LineEditor editor_{63};
std::string newSsid_;
bool newHidden_ = false;
std::unique_ptr<DialogModel> forgetDialog_;
};
} // namespace roro
+215
View File
@@ -0,0 +1,215 @@
#include "wifi_tools_app.h"
#include <M5Cardputer.h>
#include <algorithm>
#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<ScanEntry> 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<int>(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<int>(wifi_.listScan().size()));
}
requestRedraw();
}
if (wifi_.listScanDone() && static_cast<int32_t>(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<int32_t>(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<int>(list.size())) networks_.setCount(static_cast<int>(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<ChannelSighting> 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<int>(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<int>(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
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <string>
#include <vector>
#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<ScanEntry> 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
+93 -5
View File
@@ -3,18 +3,24 @@
#include "app_manager.h" #include "app_manager.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h" #include "apps/launcher_app.h"
#include "apps/settings_app.h" #include "apps/settings_app.h"
#include "apps/wifi_tools_app.h"
#include "apps/setup_app.h" #include "apps/setup_app.h"
#include "event_bus.h" #include "event_bus.h"
#include "key_mapper.h" #include "key_mapper.h"
#include "platform/nvs_store.h" #include "platform/nvs_store.h"
#include "service_manager.h" #include "service_manager.h"
#include "platform/identity.h"
#include "services/battery_service.h" #include "services/battery_service.h"
#include "services/irc_service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/power_service.h" #include "services/power_service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/wifi_service.h"
#include "settings.h" #include "settings.h"
#include "storage_paths.h"
#include "ui/notifier.h" #include "ui/notifier.h"
#include "ui/screen.h" #include "ui/screen.h"
#include "version.h" #include "version.h"
@@ -30,9 +36,12 @@ static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready. // Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery; static BatteryService* battery;
static StorageService* storage; static StorageService* storageService;
static PowerService* power; static PowerService* power;
static ClockService* clockService; static ClockService* clockService;
static SavedNetworks* savedNetworks;
static WifiService* wifi;
static IrcService* irc;
static Notifier* notifier; static Notifier* notifier;
static LauncherApp launcher; static LauncherApp launcher;
static AppManager* apps; static AppManager* apps;
@@ -58,10 +67,23 @@ static StatusInfo currentStatus() {
if (apps->foregroundTitle()) s.title = apps->foregroundTitle(); if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent(); if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
s.clock = clockService->displayTime(); s.clock = clockService->displayTime();
auto storageState = storage->state(); auto storageState = storageService->state();
s.sdPresent = storageState.present; s.sdPresent = storageState.present;
s.sdLevel = storageState.level; s.sdLevel = storageState.level;
s.compose = keyMapper.pendingCompose(); s.compose = keyMapper.pendingCompose();
s.unread = irc->totalUnread();
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; return s;
} }
@@ -75,20 +97,28 @@ void setup() {
settings.load(); settings.load();
battery = new BatteryService(bus); battery = new BatteryService(bus);
storage = new StorageService(bus); storageService = new StorageService(bus);
power = new PowerService(settings); power = new PowerService(settings);
clockService = new ClockService(settings, bus); clockService = new ClockService(settings, bus);
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 = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
services.add(*power); services.add(*power);
services.add(*clockService); services.add(*clockService);
services.add(*battery); services.add(*battery);
services.add(*storage); services.add(*storageService);
services.add(*wifi);
services.add(*irc);
apps = new AppManager(launcher); apps = new AppManager(launcher);
launcher.setManager(*apps); 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, apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})}); new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
@@ -102,6 +132,21 @@ void setup() {
} }
// Dev aid: serial commands to drive the UI without the keyboard. // 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 void serialCommands() {
static String line; static String line;
while (Serial.available()) { while (Serial.available()) {
@@ -120,6 +165,48 @@ static void serialCommands() {
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select; : k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select;
if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key)); if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key));
} }
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t');
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.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 == "irc start") irc->connect();
if (line.startsWith("irc say ")) { // irc say <buffer index> <text>
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(),
ESP.getMinFreeHeap());
if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound off") settings.setBool(Setting::Sound, false);
if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "sound on") settings.setBool(Setting::Sound, true);
if (line == "short") { if (line == "short") {
@@ -140,6 +227,7 @@ void loop() {
uint32_t now = millis(); uint32_t now = millis();
serialCommands(); serialCommands();
printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) { if (M5Cardputer.Keyboard.isChange()) {
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
+8
View File
@@ -23,6 +23,14 @@ class ClockService : public Service {
bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); }
const ClockModel& model() const { return model_; } 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.
int today() const { return model_.isSet() ? static_cast<int>(model_.utcNow(millis()) / 86400) : -1; }
// "14:05" once set; otherwise "--:--". // "14:05" once set; otherwise "--:--".
std::string displayTime() const { std::string displayTime() const {
return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--"; return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--";
+237
View File
@@ -0,0 +1,237 @@
#include "irc_service.h"
#include <Arduino.h>
#include <cstdio>
#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();
}
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;
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 "";
}
void IrcService::taskEntry(void* self) { static_cast<IrcService*>(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;
IrcConfig settings = draftConfig(); // a snapshot: the UI may apply new settings meanwhile
bool pinning = settings.tls && settings.allowSelfSigned;
conn_ = settings.tls ? static_cast<NetworkClient*>(&tlsClient_) : &plainClient_;
if (settings.tls) {
tlsClient_.setTimeout(15); // seconds, for the handshake
if (pinning) tlsClient_.setInsecure();
else tlsClient_.useBuiltinCACertBundle();
}
if (!conn_->connect(settings.host.c_str(), settings.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 (settings.pinnedSha256.empty()) {
Lock l(lock_);
config_.pinnedSha256 = fingerprint; // trust on first use
config_.save();
} else if (settings.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<char>(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();
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) : "--:--";
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", host, entry.buffer}, date), when + " " + text);
}
for (auto& n : fx.notifications)
bus_.publish(Event::withText(EventType::Notification, n.c_str(), static_cast<int32_t>(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 (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;
} 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<int32_t>(now - retryAtMs_) >= 0) {
if (!open()) scheduleRetry("could not connect to " + draftConfig().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
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <NetworkClientSecure.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <memory>
#include <string>
#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 <typename F>
auto withSession(F f) {
Lock l(lock_);
return f(*session_);
}
int totalUnread();
// 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 {
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<IrcSession> 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 bool restart_ = false;
volatile Status status_ = Status::Stopped;
bool open_ = false;
uint32_t retryAtMs_ = 0;
uint32_t lastRxMs_ = 0;
bool pingSent_ = false;
};
} // namespace roro
+144 -15
View File
@@ -4,6 +4,7 @@
#include <ff.h> #include <ff.h>
#include <sd_diskio.h> #include <sd_diskio.h>
#include <algorithm>
#include <memory> #include <memory>
#include "platform/pins.h" #include "platform/pins.h"
@@ -14,7 +15,7 @@ namespace roro {
void StorageService::start() { void StorageService::start() {
if (task_) return; if (task_) return;
lock_ = xSemaphoreCreateMutex(); lock_ = xSemaphoreCreateMutex();
xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_); xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_);
} }
void StorageService::stop() { void StorageService::stop() {
@@ -26,23 +27,52 @@ void StorageService::stop() {
} }
StorageState StorageService::state() const { StorageState StorageService::state() const {
xSemaphoreTake(lock_, portMAX_DELAY); lock();
StorageState copy = monitor_.state(); StorageState copy = monitor_.state();
xSemaphoreGive(lock_); unlock();
return copy; return copy;
} }
void StorageService::taskEntry(void* self) { void StorageService::appendLine(const std::string& path, const std::string& line) {
auto* service = static_cast<StorageService*>(self); lock();
for (;;) { size_t bytes = path.size() + line.size();
if (service->formatRequested_) { bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes;
service->format(); if (accept) {
service->formatRequested_ = false; pending_.emplace_back(path, line);
} pendingBytes_ += bytes;
service->poll(); } else {
// Sleep until the next poll, or until a format request wakes us early. dropped_++;
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPollMs));
} }
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<std::vector<StoredFile>> StorageService::listing() const {
lock();
auto copy = listing_;
unlock();
return copy;
}
void StorageService::requestDelete(std::vector<std::string> paths) {
lock();
toDelete_.insert(toDelete_.end(), paths.begin(), paths.end());
unlock();
if (task_) xTaskNotifyGive(task_);
} }
bool StorageService::requestFormat() { bool StorageService::requestFormat() {
@@ -52,6 +82,38 @@ bool StorageService::requestFormat() {
return true; return true;
} }
void StorageService::taskEntry(void* self) { static_cast<StorageService*>(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<std::string> 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() { void StorageService::format() {
if (mounted_) SD.end(); if (mounted_) SD.end();
mounted_ = false; mounted_ = false;
@@ -91,9 +153,76 @@ void StorageService::poll() {
uint64_t total = present ? SD.totalBytes() : 0; uint64_t total = present ? SD.totalBytes() : 0;
uint64_t used = present ? SD.usedBytes() : 0; uint64_t used = present ? SD.usedBytes() : 0;
xSemaphoreTake(lock_, portMAX_DELAY); lock();
monitor_.update(present, total, used); monitor_.update(present, total, used);
xSemaphoreGive(lock_); unlock();
}
void StorageService::writePending() {
lock();
std::deque<std::pair<std::string, std::string>> 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<const uint8_t*>(line.data()), line.size());
file.write('\n');
}
}
if (file) file.close();
}
namespace {
void walk(File dir, std::vector<StoredFile>& 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<std::vector<StoredFile>> result;
for (auto& category : kCleanupCategories) {
std::vector<StoredFile> 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<std::string>& 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<int32_t>(failed ? NotificationLevel::Warning : NotificationLevel::Info)));
} }
} // namespace roro } // namespace roro
+42 -7
View File
@@ -3,13 +3,20 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <deque>
#include <string>
#include <utility>
#include <vector>
#include "cleanup_plan.h"
#include "service.h" #include "service.h"
#include "storage_monitor.h" #include "storage_monitor.h"
namespace roro { namespace roro {
// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor. // Owns the microSD card. All card access happens on this Service's own task (the SD driver isn't
// Runs on its own task: a failed mount blocks for seconds and must never stall the UI. // 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 { class StorageService : public Service {
public: public:
explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {} explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {}
@@ -19,24 +26,52 @@ class StorageService : public Service {
StorageState state() const; StorageState state() const;
// Erases the whole card: one partition spanning the card, formatted FAT32. Runs on the storage // Queues one line for a Log file (created with its folders as needed). Dropped while Logs are
// task; the result arrives as a Notification. Returns false if a format is already pending. // 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<std::vector<StoredFile>> listing() const; // one vector per kCleanupCategories entry
void requestDelete(std::vector<std::string> paths);
// Erases the whole card: one partition spanning the card, formatted FAT32.
bool requestFormat(); bool requestFormat();
bool formatting() const { return formatRequested_; } bool formatting() const { return formatRequested_; }
private: 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); static void taskEntry(void* self);
void loop();
void poll(); void poll();
bool mount(); bool mount();
void format(); void format();
void writePending();
void list();
void remove(const std::vector<std::string>& paths);
void lock() const { xSemaphoreTake(lock_, portMAX_DELAY); }
void unlock() const { xSemaphoreGive(lock_); }
StorageMonitor monitor_; StorageMonitor monitor_;
bool mounted_ = false;
EventBus& bus_; EventBus& bus_;
volatile bool formatRequested_ = false; bool mounted_ = false;
TaskHandle_t task_ = nullptr; TaskHandle_t task_ = nullptr;
mutable SemaphoreHandle_t lock_ = nullptr; mutable SemaphoreHandle_t lock_ = nullptr;
// Shared with other tasks, guarded by lock_.
std::deque<std::pair<std::string, std::string>> pending_;
size_t pendingBytes_ = 0;
uint32_t dropped_ = 0;
bool listingRequested_ = false;
bool listingReady_ = false;
std::vector<std::vector<StoredFile>> listing_;
std::vector<std::string> toDelete_;
volatile bool formatRequested_ = false;
}; };
} // namespace roro } // namespace roro
+148
View File
@@ -0,0 +1,148 @@
#include "wifi_service.h"
#include <WiFi.h>
#include <esp_sntp.h>
#include <esp_wifi.h>
namespace roro {
int WifiService::rssi() const {
return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0;
}
void WifiService::startScan(int channel) {
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;
// 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(int channel) {
if (scanRunning_) {
listScanPending_ = true; // results of the scan already running will do
return;
}
listScanPending_ = true;
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;
scanRunning_ = false;
std::vector<ScanResult> results;
std::vector<ScanEntry> entries;
for (int i = 0; i < n; i++) {
std::string ssid = WiFi.SSID(i).c_str();
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));
}
void WifiService::startNtp() {
// Not configTime(): it would overwrite the POSIX TZ the Clock applies.
if (!esp_sntp_enabled()) {
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
} else {
esp_sntp_restart();
}
ntpWaiting_ = true;
}
void WifiService::apply(const WifiController::Step& step) {
using Action = WifiController::Action;
switch (step.action) {
case Action::None: break;
case Action::StartScan: startScan(); break;
case Action::Connect: WiFi.begin(step.ssid.c_str(), step.password.empty() ? nullptr : step.password.c_str()); break;
case Action::Disconnect: WiFi.disconnect(); break;
case Action::RadioOff:
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
radioInitialised_ = scanRunning_ = false;
break;
case Action::StartMonitor:
startScan(); // makes sure the radio is up
WiFi.disconnect();
break;
case Action::StopMonitorAndScan:
esp_wifi_set_promiscuous(false);
startScan();
break;
case Action::StopMonitorAndOff:
esp_wifi_set_promiscuous(false);
WiFi.mode(WIFI_OFF);
radioInitialised_ = scanRunning_ = false;
break;
}
}
void WifiService::tick(uint32_t nowMs) {
using State = WifiController::State;
if (scanRunning_) finishScan(nowMs);
wl_status_t status = WiFi.status();
if (controller_.state() == State::Connecting) {
if (status == WL_CONNECTED) {
apply(controller_.connected(nowMs));
startNtp();
} else if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
apply(controller_.disconnected(nowMs));
}
} else if (controller_.state() == State::Connected && status != WL_CONNECTED) {
apply(controller_.disconnected(nowMs));
}
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled)));
if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
ntpWaiting_ = false;
clock_.set(static_cast<int64_t>(time(nullptr)), TimeSource::Ntp);
}
}
} // namespace roro
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <vector>
#include "event_bus.h"
#include "saved_networks.h"
#include "service.h"
#include "services/clock_service.h"
#include "settings.h"
#include "wifi_controller.h"
namespace roro {
struct ScanEntry {
std::string ssid; // 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,
// 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;
// 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<ScanEntry>& 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()); }
void requestMonitor() { apply(controller_.requestMonitor()); }
void releaseMonitor() { apply(controller_.releaseMonitor(settings_.getBool(Setting::WifiEnabled))); }
private:
void apply(const WifiController::Step& step);
void startScan(int channel = 0);
void finishScan(uint32_t nowMs);
void startNtp();
Settings& settings_;
SavedNetworks& saved_;
ClockService& clock_;
WifiController controller_;
bool scanRunning_ = false;
bool listScanPending_ = false;
std::vector<ScanEntry> listScan_;
uint32_t listScanSeq_ = 0;
bool ntpWaiting_ = false;
bool radioInitialised_ = false;
};
} // namespace roro
+1 -1
View File
@@ -5,7 +5,7 @@
namespace roro { namespace roro {
bool Screen::begin() { bool Screen::begin() {
canvas_.setColorDepth(16); canvas_.setColorDepth(8); // RGB332: saves 32 KB of RAM (M1 Q46); theme colours are converted
canvas_.setPsram(false); canvas_.setPsram(false);
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
} }
+1 -1
View File
@@ -9,7 +9,7 @@ namespace roro {
// Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display. // Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display.
class Screen { class Screen {
public: 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); void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
Canvas& canvas() { return canvas_; } Canvas& canvas() { return canvas_; }
+16
View File
@@ -29,9 +29,25 @@ void statusBar(Canvas& c, const StatusInfo& info) {
x -= c.textWidth(text.c_str()) + 6; x -= c.textWidth(text.c_str()) + 6;
}; };
right(info.clock, kText); right(info.clock, kText);
if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage);
if (info.batteryPercent >= 0) if (info.batteryPercent >= 0)
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText); right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted); 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); if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent);
c.setTextDatum(top_left); c.setTextDatum(top_left);
} }
+5 -1
View File
@@ -21,10 +21,14 @@ struct StatusInfo {
bool sdPresent = false; bool sdPresent = false;
int sdLevel = 0; // StorageMonitor level int sdLevel = 0; // StorageMonitor level
char compose = 0; // pending Compose Key state 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 { bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && 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 && unread == o.unread;
} }
bool operator!=(const StatusInfo& o) const { return !(*this == o); } bool operator!=(const StatusInfo& o) const { return !(*this == o); }
}; };
@@ -0,0 +1,74 @@
#include <unity.h>
#include "cleanup_plan.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static const int kToday = 20728; // 2026-10-02
static std::vector<StoredFile> 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();
}
+7
View File
@@ -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()); 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() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_clock_is_unset_until_a_source_sets_it); 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_utc_now_survives_millis_wraparound);
RUN_TEST(test_relative_age_formatting); RUN_TEST(test_relative_age_formatting);
RUN_TEST(test_local_time_in_brussels_summer_and_winter); RUN_TEST(test_local_time_in_brussels_summer_and_winter);
RUN_TEST(test_local_date_in_brussels);
return UNITY_END(); return UNITY_END();
} }
+88
View File
@@ -0,0 +1,88 @@
#include <unity.h>
#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());
}
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();
}
@@ -0,0 +1,74 @@
#include <unity.h>
#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();
}
+375
View File
@@ -0,0 +1,375 @@
#include <unity.h>
#include <algorithm>
#include <memory>
#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<IrcSession> 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<int>(IrcLine::Kind::Action), static_cast<int>(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());
}
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());
}
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);
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);
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();
}
@@ -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_EQUAL(60, s.getInt(Setting::OffTimeoutS));
TEST_ASSERT_TRUE(s.getBool(Setting::Sound)); TEST_ASSERT_TRUE(s.getBool(Setting::Sound));
TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw)); 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("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()); 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)); 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() { void test_names_are_text_rows_with_their_byte_limits() {
Fixture f; Fixture f;
int shortName = f.row(SettingsMenu::Row::ShortName); int shortName = f.row(SettingsMenu::Row::ShortName);
@@ -108,6 +116,7 @@ int main() {
RUN_TEST(test_choosing_applies_the_setting); RUN_TEST(test_choosing_applies_the_setting);
RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason); 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_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); RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
return UNITY_END(); return UNITY_END();
} }
@@ -0,0 +1,54 @@
#include <unity.h>
#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\"g<h>i").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();
}
@@ -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();
}
@@ -0,0 +1,78 @@
#include <unity.h>
#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();
}