Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83eb791924 | ||
|
|
e7da9bd388 | ||
|
|
dd16352509 | ||
|
|
4cd6a63498 | ||
|
|
076baa77d1 | ||
|
|
74a341418e | ||
|
|
311eb38723 | ||
|
|
d3948ccc3a | ||
|
|
1a0baf4ac7 | ||
|
|
d8fe676c65 | ||
|
|
0c470922bc |
+19
-2
@@ -41,9 +41,25 @@ Rebroadcasting another Node's packet so it travels further across the mesh.
|
||||
_Avoid_: forwarding, repeating
|
||||
|
||||
**Wi-Fi Service**:
|
||||
The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, *Connected* (joined to an access point) or *Monitoring* (passively observing). It's Off unless an App asks for another mode.
|
||||
The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, *Connected* (joined to a Saved Network) or *Monitoring* (passively observing). When Wi-Fi is enabled in Settings, it stays Connected whenever a Saved Network is in range. It goes Monitoring only while Wi-Fi Tools needs it, then reconnects.
|
||||
_Avoid_: network manager
|
||||
|
||||
**Saved Network**:
|
||||
A Wi-Fi network the device may join on its own (name, password). When several are in range, the strongest wins.
|
||||
_Avoid_: profile, known network
|
||||
|
||||
**IRC Service**:
|
||||
The Service that keeps the IRC connection alive in the background once the IRC App has started it, until the user disconnects. It reconnects after drops, and pauses while the Wi-Fi Service is Monitoring. It does not start by itself after a reboot.
|
||||
_Avoid_: IRC client (that's the App)
|
||||
|
||||
**Buffer**:
|
||||
One IRC conversation shown in the IRC App: the server, a channel, or a private chat with one nick. Each Buffer counts its unread messages.
|
||||
_Avoid_: window, tab, room
|
||||
|
||||
**Mention**:
|
||||
An IRC message containing the user's nick, or any private message. Mentions raise Notifications; other traffic only counts as unread.
|
||||
_Avoid_: highlight, ping
|
||||
|
||||
**Status Bar**:
|
||||
The strip shown on every screen with system state at a glance: battery, GNSS fix, mesh activity, Wi-Fi mode and unread count.
|
||||
_Avoid_: header, top bar
|
||||
@@ -93,7 +109,7 @@ The screen where the user deletes old Logs and Captures by category and age, wit
|
||||
- The **Launcher** starts **Apps**. Exactly one **App** is in the foreground.
|
||||
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
||||
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
||||
- The **Wi-Fi Service** serves one **App** at a time. Monitoring and Connected are mutually exclusive.
|
||||
- The **Wi-Fi Service** is either Connected or Monitoring, never both. Monitoring pauses the **IRC Service**, which reconnects and rejoins its **Buffers** afterwards.
|
||||
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
||||
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
||||
- Every transmission is bounded by the **Region** and its **Duty Cycle Budget**.
|
||||
@@ -103,3 +119,4 @@ The screen where the user deletes old Logs and Captures by category and age, wit
|
||||
## Flagged ambiguities
|
||||
|
||||
- "LoRa" was used both for the radio and for the mesh. Resolved: say **Mesh Service** for the always-on participation and **Mesh Protocol** for the on-air format. The LoRa Scanner **App** uses the radio directly and doesn't speak a **Mesh Protocol**.
|
||||
- "Channel" has three meanings here. Resolved: an unqualified **Channel** is the mesh one. The others are always qualified as "IRC channel" (a kind of **Buffer**) and "Wi-Fi channel" (radio frequency, 1–13).
|
||||
|
||||
@@ -47,5 +47,12 @@ sudo usermod -aG dialout "$USER"
|
||||
| `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press |
|
||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <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.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# M1 — Wi-Fi Service, Wi-Fi Tools, IRC
|
||||
|
||||
**Status:** done on 2026-10-03, tagged `v0.2.0`.
|
||||
|
||||
## Outcome
|
||||
|
||||
The "Done when" items below work on the device, against irc.libera.chat over TLS, with the following changes.
|
||||
|
||||
- **Changed:**
|
||||
- **Wi-Fi Tools is built from ordinary scans only:** networks nearby, channel occupancy and a signal tracker. Monitoring-mode views and captures are deferred (revised Q42). The Monitoring mode stays in the Wi-Fi Service and the IRC pause logic, unused for now.
|
||||
- **The frame buffer is 8-bit colour** (Q46), applied as soon as IRC on TLS measured a 51 KB low.
|
||||
- **Not done, carried over:**
|
||||
- **Probe-request logs and Wi-Fi captures** are still listed as Storage Clean-up categories, but nothing writes to them yet.
|
||||
- **IRC has no input history** (up-arrow recall). Each Buffer keeps only 50 lines in RAM; the full history is in the Logs.
|
||||
- **Measured on the device:**
|
||||
- With Wi-Fi and IRC on TLS: about 92 KB free heap, about 77–81 KB at the lowest. The floor was 40 KB.
|
||||
- 44% of the app flash used.
|
||||
- 231 host tests.
|
||||
|
||||
**Goal:** the device joins your Wi-Fi by itself, syncs its clock, keeps an IRC session alive in the background, and gives passive Wi-Fi diagnostics with full-frame captures.
|
||||
|
||||
## Decisions (design round 2026-10-02)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q40 | Up to 8 **Saved Networks**, joined strongest-first. Added from a scan or as a hidden network. Open networks are allowed; enterprise (802.1X) is not. |
|
||||
| Q41 / Q48 | The Wi-Fi Service stays **Connected** whenever enabled and a Saved Network is in range. Settings has a Wi-Fi On/Off switch. **Monitoring** happens only while Wi-Fi Tools is open; the device reconnects on exit. |
|
||||
| Q42 | *Revised 2026-10-02:* Wi-Fi Tools is built from ordinary scans only (access points, channel occupancy, signal tracker). Monitoring-mode views and captures are deferred, to be revisited later. |
|
||||
| Q43 | The signal tracker clicks faster as the signal gets stronger. On by default, mutable. |
|
||||
| Q44 | IRC shows one Buffer at a time; Tab cycles Buffers. Commands: `/join /part /msg /me /nick /topic /names /quit /raw`. Logs go to `/irc/<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. |
|
||||
| Q46 | If RAM is short: switch the frame buffer to 8-bit colour. Keep the AtomS3 Wi-Fi co-processor in mind. |
|
||||
| Q47 | Passwords are stored in NVS without flash encryption (revisit before any public release). |
|
||||
| IRC | The **IRC Service** stays connected in the background once the App has started it, until `/quit` or disconnect. It does not start at boot. |
|
||||
| Q49 | Opening a Monitoring view while IRC is connected asks first. IRC then pauses, and reconnects and rejoins afterwards; its Buffers show the gap. |
|
||||
| Q50 | Reconnects after drops wait 5 s, 10 s, 30 s … up to 5 min. |
|
||||
| Q51 | Mentions and private messages raise Notifications. Other traffic only counts as unread (Status Bar shows the total). |
|
||||
| M0 | The **Storage Clean-up** screen lands here, once IRC Logs and Captures exist. |
|
||||
|
||||
## Done when
|
||||
|
||||
- Wi-Fi joins the strongest Saved Network at boot. The Status Bar shows Wi-Fi state, and the clock is set over NTP.
|
||||
- Settings → Wi-Fi: On/Off, scan and add a network (with password), add a hidden network, forget a network.
|
||||
- The IRC App configures one server (host, port, TLS, nick, SASL or NickServ, auto-join IRC channels). It connects and keeps running after you leave the App. Mentions notify.
|
||||
- IRC reconnects after Wi-Fi loss or a server drop, and rejoins its IRC channels. Logs are written to the SD card (and stop past 90% usage).
|
||||
- Wi-Fi Tools: access-point list, channel occupancy with the quietest of channels 1/6/11, and a signal tracker that clicks faster as the signal gets stronger. None of it interrupts the connection or IRC.
|
||||
- Settings → Storage → Clean-up deletes IRC Logs, probe Logs and Wi-Fi Captures older than a chosen age, with a preview of the space freed.
|
||||
- Free heap stays above about 40 KB with Wi-Fi, TLS-connected IRC and the UI running.
|
||||
|
||||
## Work breakdown (proposed order)
|
||||
|
||||
1. **Memory baseline:** measure the heap with Wi-Fi Connected plus one TLS connection. Switch the frame buffer to 8-bit if the margin is too thin.
|
||||
2. **Wi-Fi Service:**
|
||||
- Saved Networks in NVS.
|
||||
- Choosing the network (host-tested).
|
||||
- The Off / Connecting / Connected / Monitoring state machine (host-tested).
|
||||
- NTP → Clock.
|
||||
- Status Bar indicator.
|
||||
- Settings → Wi-Fi pages.
|
||||
3. **Log writing:** a Log writer that respects the Storage rules, daily files, and the Storage Clean-up screen (selection logic host-tested).
|
||||
4. **IRC Service** (host-tested core):
|
||||
- message parser and serializer;
|
||||
- registration, SASL PLAIN and NickServ;
|
||||
- PING/PONG;
|
||||
- Buffers with unread counts and Mention detection;
|
||||
- reconnect backoff, and pause/resume around Monitoring.
|
||||
|
||||
Then the TLS transport with the certificate bundle.
|
||||
5. **IRC App:** Buffer view, input line, command parser (host-tested), server settings page.
|
||||
6. **Wi-Fi Tools App:** access-point list, channel occupancy and signal tracker from scans (occupancy and tracker logic host-tested). Monitoring mode is deferred.
|
||||
@@ -20,7 +20,8 @@ const RowDef kRows[] = {
|
||||
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
|
||||
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"},
|
||||
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Storage, Kind::Page, "Storage"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||
{Row::Storage, Kind::Page, "Storage"},
|
||||
{Row::About, Kind::Page, "About"},
|
||||
};
|
||||
|
||||
@@ -78,6 +79,7 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
|
||||
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
|
||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace roro {
|
||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Storage, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, About };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||
|
||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -34,6 +34,15 @@ std::string ClockModel::formatLocalTime(int64_t utcSeconds) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string ClockModel::formatLocalDate(int64_t utcSeconds) {
|
||||
time_t t = static_cast<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) {
|
||||
char buf[24];
|
||||
if (seconds < 60) return "now";
|
||||
|
||||
@@ -21,6 +21,7 @@ class ClockModel {
|
||||
|
||||
static void applyTimezone(const char* posixTz);
|
||||
static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM"
|
||||
static std::string formatLocalDate(int64_t utcSeconds); // "YYYY-MM-DD"
|
||||
static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago"
|
||||
|
||||
private:
|
||||
|
||||
@@ -28,6 +28,7 @@ const Definition kDefinitions[] = {
|
||||
{"off_s", Kind::Int, 60, nullptr, 10, 3600},
|
||||
{"sound", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
@@ -20,6 +20,7 @@ enum class Setting : uint8_t {
|
||||
OffTimeoutS, // int, 10..3600, above DimTimeoutS
|
||||
Sound, // bool
|
||||
ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses
|
||||
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -7,7 +7,6 @@ default_envs = cardputer-adv
|
||||
[env]
|
||||
extra_scripts = pre:scripts/version.py
|
||||
test_framework = unity
|
||||
lib_ldf_mode = deep+
|
||||
|
||||
[env:cardputer-adv]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||
@@ -25,4 +24,5 @@ test_ignore = *
|
||||
; Host-side unit tests for pure logic (no hardware).
|
||||
[env:native]
|
||||
platform = native
|
||||
lib_ldf_mode = deep+
|
||||
build_flags = -std=gnu++17
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -14,7 +14,6 @@ using Row = SettingsMenu::Row;
|
||||
|
||||
void SettingsApp::onEnter() {
|
||||
page_ = Page::Menu;
|
||||
eraseDialog_.reset();
|
||||
list_.setCount(menu_.count());
|
||||
}
|
||||
|
||||
@@ -28,8 +27,13 @@ bool SettingsApp::onKey(const KeyEvent& e) {
|
||||
case Page::Menu: return onMenuKey(e);
|
||||
case Page::Text: return onTextKey(e);
|
||||
case Page::Choice: return onChoiceKey(e);
|
||||
case Page::Storage: return onStorageKey(e);
|
||||
case Page::Storage:
|
||||
if (!storagePage_.onKey(e)) page_ = Page::Menu;
|
||||
return true;
|
||||
case Page::About: return onAboutKey(e);
|
||||
case Page::Wifi:
|
||||
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -61,7 +65,19 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
||||
break;
|
||||
case Kind::Toggle: menu_.toggle(i); break;
|
||||
case Kind::Slider: break;
|
||||
case Kind::Page: page_ = menu_.row(i) == Row::Storage ? Page::Storage : Page::About; break;
|
||||
case Kind::Page:
|
||||
switch (menu_.row(i)) {
|
||||
case Row::Storage:
|
||||
page_ = Page::Storage;
|
||||
storagePage_.enter();
|
||||
break;
|
||||
case Row::Wifi:
|
||||
page_ = Page::Wifi;
|
||||
wifiPage_.enter();
|
||||
break;
|
||||
default: page_ = Page::About; break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -100,21 +116,6 @@ bool SettingsApp::onChoiceKey(const KeyEvent& e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SettingsApp::onStorageKey(const KeyEvent& e) {
|
||||
if (eraseDialog_) {
|
||||
eraseDialog_->onKey(e);
|
||||
if (eraseDialog_->result() == 1 && !d_.storage.requestFormat()) warn("A format is already running");
|
||||
if (eraseDialog_->result() != DialogModel::kPending) eraseDialog_.reset();
|
||||
return true;
|
||||
}
|
||||
if (e.key == Key::Select) {
|
||||
if (d_.storage.state().present) eraseDialog_.reset(new DialogModel({"Cancel", "Erase"}));
|
||||
else warn("No SD card");
|
||||
}
|
||||
if (e.key == Key::Back) page_ = Page::Menu;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
||||
if (e.key == Key::Back) page_ = Page::Menu;
|
||||
if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo
|
||||
@@ -123,31 +124,13 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
||||
|
||||
void SettingsApp::update(uint32_t nowMs) {
|
||||
// Live values on Storage and About.
|
||||
if ((page_ == Page::Storage || page_ == Page::About) && nowMs - lastRefreshMs_ >= 1000) {
|
||||
bool live = page_ == Page::Storage || page_ == Page::About || (page_ == Page::Wifi && wifiPage_.live());
|
||||
if (live && nowMs - lastRefreshMs_ >= 500) {
|
||||
lastRefreshMs_ = nowMs;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
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> lines;
|
||||
char buf[64];
|
||||
@@ -197,13 +180,9 @@ void SettingsApp::draw(Canvas& c) {
|
||||
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
|
||||
break;
|
||||
}
|
||||
case Page::Storage:
|
||||
widgets::textLines(c, storageLines(), 0, area);
|
||||
if (eraseDialog_)
|
||||
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
|
||||
*eraseDialog_);
|
||||
break;
|
||||
case Page::Storage: storagePage_.draw(c); break;
|
||||
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
|
||||
case Page::Wifi: wifiPage_.draw(c); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -13,6 +13,8 @@
|
||||
#include "services/battery_service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "apps/storage_page.h"
|
||||
#include "apps/wifi_settings_page.h"
|
||||
#include "settings_menu.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
@@ -25,37 +27,44 @@ struct SettingsAppDeps {
|
||||
BatteryService& battery;
|
||||
StorageService& storage;
|
||||
ClockService& clock;
|
||||
WifiService& wifi;
|
||||
SavedNetworks& savedNetworks;
|
||||
};
|
||||
|
||||
// Settings: every user-facing setting, plus the Storage and About pages.
|
||||
class SettingsApp : public App {
|
||||
public:
|
||||
explicit SettingsApp(const SettingsAppDeps& deps) : d_(deps), menu_(deps.settings) {}
|
||||
explicit SettingsApp(const SettingsAppDeps& deps)
|
||||
: d_(deps),
|
||||
menu_(deps.settings),
|
||||
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
|
||||
storagePage_(deps.storage, deps.clock, deps.bus) {}
|
||||
void onEnter() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
bool textEntryActive() const override { return page_ == Page::Text; }
|
||||
bool textEntryActive() const override {
|
||||
return page_ == Page::Text || (page_ == Page::Wifi && wifiPage_.textEntryActive());
|
||||
}
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class Page { Menu, Text, Choice, Storage, About };
|
||||
enum class Page { Menu, Text, Choice, Storage, About, Wifi };
|
||||
|
||||
bool onMenuKey(const KeyEvent& e);
|
||||
bool onTextKey(const KeyEvent& e);
|
||||
bool onChoiceKey(const KeyEvent& e);
|
||||
bool onStorageKey(const KeyEvent& e);
|
||||
bool onAboutKey(const KeyEvent& e);
|
||||
void warn(const std::string& text);
|
||||
std::vector<std::string> storageLines() const;
|
||||
std::vector<std::string> aboutLines() const;
|
||||
|
||||
SettingsAppDeps d_;
|
||||
SettingsMenu menu_;
|
||||
WifiSettingsPage wifiPage_;
|
||||
StoragePage storagePage_;
|
||||
Page page_ = Page::Menu;
|
||||
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
||||
LineEditor editor_{39};
|
||||
std::unique_ptr<DialogModel> eraseDialog_;
|
||||
int editingRow_ = 0;
|
||||
uint32_t lastRefreshMs_ = 0;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -3,18 +3,24 @@
|
||||
|
||||
#include "app_manager.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/wifi_tools_app.h"
|
||||
#include "apps/setup_app.h"
|
||||
#include "event_bus.h"
|
||||
#include "key_mapper.h"
|
||||
#include "platform/nvs_store.h"
|
||||
#include "service_manager.h"
|
||||
#include "platform/identity.h"
|
||||
#include "services/battery_service.h"
|
||||
#include "services/irc_service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/wifi_service.h"
|
||||
#include "settings.h"
|
||||
#include "storage_paths.h"
|
||||
#include "ui/notifier.h"
|
||||
#include "ui/screen.h"
|
||||
#include "version.h"
|
||||
@@ -30,9 +36,12 @@ static Screen screen;
|
||||
|
||||
// Constructed in setup(), after the hardware and Settings are ready.
|
||||
static BatteryService* battery;
|
||||
static StorageService* storage;
|
||||
static StorageService* storageService;
|
||||
static PowerService* power;
|
||||
static ClockService* clockService;
|
||||
static SavedNetworks* savedNetworks;
|
||||
static WifiService* wifi;
|
||||
static IrcService* irc;
|
||||
static Notifier* notifier;
|
||||
static LauncherApp launcher;
|
||||
static AppManager* apps;
|
||||
@@ -58,10 +67,23 @@ static StatusInfo currentStatus() {
|
||||
if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
|
||||
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
|
||||
s.clock = clockService->displayTime();
|
||||
auto storageState = storage->state();
|
||||
auto storageState = storageService->state();
|
||||
s.sdPresent = storageState.present;
|
||||
s.sdLevel = storageState.level;
|
||||
s.compose = keyMapper.pendingCompose();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -75,20 +97,28 @@ void setup() {
|
||||
settings.load();
|
||||
|
||||
battery = new BatteryService(bus);
|
||||
storage = new StorageService(bus);
|
||||
storageService = new StorageService(bus);
|
||||
power = new PowerService(settings);
|
||||
clockService = new ClockService(settings, bus);
|
||||
savedNetworks = new SavedNetworks(nvs);
|
||||
savedNetworks->load();
|
||||
wifi = new WifiService(settings, *savedNetworks, *clockService);
|
||||
irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
|
||||
notifier = new Notifier(bus, settings);
|
||||
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
|
||||
services.add(*power);
|
||||
services.add(*clockService);
|
||||
services.add(*battery);
|
||||
services.add(*storage);
|
||||
services.add(*storageService);
|
||||
services.add(*wifi);
|
||||
services.add(*irc);
|
||||
|
||||
apps = new AppManager(launcher);
|
||||
launcher.setManager(*apps);
|
||||
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
|
||||
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})});
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
||||
|
||||
@@ -102,6 +132,21 @@ void setup() {
|
||||
}
|
||||
|
||||
// Dev aid: serial commands to drive the UI without the keyboard.
|
||||
static bool listingWanted = false;
|
||||
|
||||
static void printListingWhenReady() {
|
||||
if (!listingWanted || !storageService->listingReady()) return;
|
||||
listingWanted = false;
|
||||
auto listing = storageService->listing();
|
||||
for (size_t i = 0; i < listing.size(); i++) {
|
||||
uint64_t bytes = 0;
|
||||
for (auto& f : listing[i]) bytes += f.bytes;
|
||||
Serial.printf("sd list: %-20s %u files, %s\n", kCleanupCategories[i].label, (unsigned)listing[i].size(),
|
||||
formatBytes(bytes).c_str());
|
||||
for (auto& f : listing[i]) Serial.printf(" %s (%u B)\n", f.path.c_str(), (unsigned)f.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
static void serialCommands() {
|
||||
static String line;
|
||||
while (Serial.available()) {
|
||||
@@ -120,6 +165,48 @@ static void serialCommands() {
|
||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select;
|
||||
if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key));
|
||||
}
|
||||
if (line.startsWith("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 on") settings.setBool(Setting::Sound, true);
|
||||
if (line == "short") {
|
||||
@@ -140,6 +227,7 @@ void loop() {
|
||||
uint32_t now = millis();
|
||||
|
||||
serialCommands();
|
||||
printListingWhenReady();
|
||||
M5Cardputer.update();
|
||||
if (M5Cardputer.Keyboard.isChange()) {
|
||||
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
|
||||
|
||||
@@ -23,6 +23,14 @@ class ClockService : public Service {
|
||||
bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); }
|
||||
const ClockModel& model() const { return model_; }
|
||||
|
||||
// UTC seconds, or -1 if the clock isn't set.
|
||||
int64_t utcNow() const { return model_.isSet() ? model_.utcNow(millis()) : -1; }
|
||||
|
||||
// Local "YYYY-MM-DD" once set; otherwise empty (Logs then go to undated files).
|
||||
std::string localDate() const { return model_.isSet() ? ClockModel::formatLocalDate(model_.utcNow(millis())) : ""; }
|
||||
// Days since 1970-01-01 (UTC), or -1 if the clock isn't set.
|
||||
int today() const { return model_.isSet() ? static_cast<int>(model_.utcNow(millis()) / 86400) : -1; }
|
||||
|
||||
// "14:05" once set; otherwise "--:--".
|
||||
std::string displayTime() const {
|
||||
return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--";
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <ff.h>
|
||||
#include <sd_diskio.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
|
||||
#include "platform/pins.h"
|
||||
@@ -14,7 +15,7 @@ namespace roro {
|
||||
void StorageService::start() {
|
||||
if (task_) return;
|
||||
lock_ = xSemaphoreCreateMutex();
|
||||
xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_);
|
||||
xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_);
|
||||
}
|
||||
|
||||
void StorageService::stop() {
|
||||
@@ -26,23 +27,52 @@ void StorageService::stop() {
|
||||
}
|
||||
|
||||
StorageState StorageService::state() const {
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
lock();
|
||||
StorageState copy = monitor_.state();
|
||||
xSemaphoreGive(lock_);
|
||||
unlock();
|
||||
return copy;
|
||||
}
|
||||
|
||||
void StorageService::taskEntry(void* self) {
|
||||
auto* service = static_cast<StorageService*>(self);
|
||||
for (;;) {
|
||||
if (service->formatRequested_) {
|
||||
service->format();
|
||||
service->formatRequested_ = false;
|
||||
void StorageService::appendLine(const std::string& path, const std::string& line) {
|
||||
lock();
|
||||
size_t bytes = path.size() + line.size();
|
||||
bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes;
|
||||
if (accept) {
|
||||
pending_.emplace_back(path, line);
|
||||
pendingBytes_ += bytes;
|
||||
} else {
|
||||
dropped_++;
|
||||
}
|
||||
service->poll();
|
||||
// Sleep until the next poll, or until a format request wakes us early.
|
||||
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() {
|
||||
@@ -52,6 +82,38 @@ bool StorageService::requestFormat() {
|
||||
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() {
|
||||
if (mounted_) SD.end();
|
||||
mounted_ = false;
|
||||
@@ -91,9 +153,76 @@ void StorageService::poll() {
|
||||
uint64_t total = present ? SD.totalBytes() : 0;
|
||||
uint64_t used = present ? SD.usedBytes() : 0;
|
||||
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
lock();
|
||||
monitor_.update(present, total, used);
|
||||
xSemaphoreGive(lock_);
|
||||
unlock();
|
||||
}
|
||||
|
||||
void StorageService::writePending() {
|
||||
lock();
|
||||
std::deque<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
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "cleanup_plan.h"
|
||||
#include "service.h"
|
||||
#include "storage_monitor.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor.
|
||||
// Runs on its own task: a failed mount blocks for seconds and must never stall the UI.
|
||||
// Owns the microSD card. All card access happens on this Service's own task (the SD driver isn't
|
||||
// safe across tasks, and a failed mount blocks for seconds): it mounts and watches usage, writes
|
||||
// queued Log lines in batches, lists files for Storage Clean-up, deletes, and formats.
|
||||
class StorageService : public Service {
|
||||
public:
|
||||
explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {}
|
||||
@@ -19,24 +26,52 @@ class StorageService : public Service {
|
||||
|
||||
StorageState state() const;
|
||||
|
||||
// Erases the whole card: one partition spanning the card, formatted FAT32. Runs on the storage
|
||||
// task; the result arrives as a Notification. Returns false if a format is already pending.
|
||||
// Queues one line for a Log file (created with its folders as needed). Dropped while Logs are
|
||||
// paused (over 90 % full, or no card) or if too much is already waiting.
|
||||
void appendLine(const std::string& path, const std::string& line);
|
||||
uint32_t droppedLines() const { return dropped_; }
|
||||
|
||||
// Storage Clean-up: list every file of every category, then delete a selection.
|
||||
void requestListing();
|
||||
bool listingReady() const;
|
||||
std::vector<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 formatting() const { return formatRequested_; }
|
||||
|
||||
private:
|
||||
static constexpr uint32_t kPollMs = 15000;
|
||||
static constexpr uint32_t kWakeMs = 1000;
|
||||
static constexpr uint32_t kPollEvery = 15; // wake-ups between usage checks
|
||||
static constexpr size_t kMaxPendingBytes = 16 * 1024;
|
||||
|
||||
static void taskEntry(void* self);
|
||||
void loop();
|
||||
void poll();
|
||||
bool mount();
|
||||
void format();
|
||||
void writePending();
|
||||
void list();
|
||||
void remove(const std::vector<std::string>& paths);
|
||||
void lock() const { xSemaphoreTake(lock_, portMAX_DELAY); }
|
||||
void unlock() const { xSemaphoreGive(lock_); }
|
||||
|
||||
StorageMonitor monitor_;
|
||||
bool mounted_ = false;
|
||||
EventBus& bus_;
|
||||
volatile bool formatRequested_ = false;
|
||||
bool mounted_ = false;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
mutable SemaphoreHandle_t lock_ = nullptr;
|
||||
|
||||
// Shared with other tasks, guarded by lock_.
|
||||
std::deque<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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
namespace roro {
|
||||
|
||||
bool Screen::begin() {
|
||||
canvas_.setColorDepth(16);
|
||||
canvas_.setColorDepth(8); // RGB332: saves 32 KB of RAM (M1 Q46); theme colours are converted
|
||||
canvas_.setPsram(false);
|
||||
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ namespace roro {
|
||||
// Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display.
|
||||
class Screen {
|
||||
public:
|
||||
bool begin(); // allocates the frame buffer (~64 KB)
|
||||
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
|
||||
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
|
||||
Canvas& canvas() { return canvas_; }
|
||||
|
||||
|
||||
@@ -29,9 +29,25 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
||||
x -= c.textWidth(text.c_str()) + 6;
|
||||
};
|
||||
right(info.clock, kText);
|
||||
if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage);
|
||||
if (info.batteryPercent >= 0)
|
||||
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
|
||||
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted);
|
||||
switch (info.wifi) {
|
||||
case StatusInfo::Wifi::Connected: {
|
||||
// Three signal bars, then the label.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int h = 2 + 2 * i;
|
||||
c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kMuted);
|
||||
}
|
||||
x -= 14;
|
||||
right("W", kText);
|
||||
break;
|
||||
}
|
||||
case StatusInfo::Wifi::Searching: right("W?", kMuted); break;
|
||||
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
|
||||
case StatusInfo::Wifi::None: break;
|
||||
}
|
||||
if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
+5
-1
@@ -21,10 +21,14 @@ struct StatusInfo {
|
||||
bool sdPresent = false;
|
||||
int sdLevel = 0; // StorageMonitor level
|
||||
char compose = 0; // pending Compose Key state
|
||||
enum class Wifi { None, Searching, Connected, Monitoring } wifi = Wifi::None;
|
||||
int wifiBars = 0; // 0..3 when Connected
|
||||
int unread = 0; // unread messages across Services
|
||||
|
||||
bool operator==(const StatusInfo& o) const {
|
||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose;
|
||||
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); }
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -61,6 +61,12 @@ void test_local_time_in_brussels_summer_and_winter() {
|
||||
TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str());
|
||||
}
|
||||
|
||||
void test_local_date_in_brussels() {
|
||||
ClockModel::applyTimezone(kBrussels);
|
||||
// 2026-07-01 23:30Z is already July 2nd in Brussels (UTC+2)
|
||||
TEST_ASSERT_EQUAL_STRING("2026-07-02", ClockModel::formatLocalDate(k2026_07_01_1200Z + 11 * 3600 + 1800).c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_clock_is_unset_until_a_source_sets_it);
|
||||
@@ -70,5 +76,6 @@ int main() {
|
||||
RUN_TEST(test_utc_now_survives_millis_wraparound);
|
||||
RUN_TEST(test_relative_age_formatting);
|
||||
RUN_TEST(test_local_time_in_brussels_summer_and_winter);
|
||||
RUN_TEST(test_local_date_in_brussels);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -25,6 +25,7 @@ void test_defaults_when_store_is_empty() {
|
||||
TEST_ASSERT_EQUAL(60, s.getInt(Setting::OffTimeoutS));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::Sound));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled));
|
||||
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@ void test_brightness_steps_with_left_right_within_bounds() {
|
||||
TEST_ASSERT_EQUAL(10, f.settings.getInt(Setting::Brightness));
|
||||
}
|
||||
|
||||
void test_wifi_is_a_page() {
|
||||
Fixture f;
|
||||
int wifi = f.row(SettingsMenu::Row::Wifi);
|
||||
TEST_ASSERT_TRUE(wifi >= 0);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Page), static_cast<int>(f.menu.kind(wifi)));
|
||||
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(wifi).c_str());
|
||||
}
|
||||
|
||||
void test_names_are_text_rows_with_their_byte_limits() {
|
||||
Fixture f;
|
||||
int shortName = f.row(SettingsMenu::Row::ShortName);
|
||||
@@ -108,6 +116,7 @@ int main() {
|
||||
RUN_TEST(test_choosing_applies_the_setting);
|
||||
RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason);
|
||||
RUN_TEST(test_brightness_steps_with_left_right_within_bounds);
|
||||
RUN_TEST(test_wifi_is_a_page);
|
||||
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
Reference in New Issue
Block a user