Merge IRC polish and Wi-Fi scan tools

IRC: join after NickServ login, keyed auto-join, SASL->NickServ
fallback, nick regain; Alt scrolls and Up/Down recall sent lines; /j.
Wi-Fi Tools: sort and filter the network list, and scan logging to CSV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-03 21:04:06 +02:00
co-authored by Claude Opus 5.5
26 changed files with 871 additions and 101 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ The `opt` key used as a dead key. Pressing it and then a base letter types an ac
_Avoid_: modifier, alt
**Log**:
History the device records automatically in the background: mesh message history, IRC logs, probe-request logs.
History the device records automatically in the background: mesh message history, IRC logs, Wi-Fi scan logs.
_Avoid_: history file, dump
**Capture**:
+2 -1
View File
@@ -44,12 +44,13 @@ sudo usermod -aG dialout "$USER"
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a 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 |
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
| `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 |
+1 -1
View File
@@ -10,7 +10,7 @@ The "Done when" items below work on the device, against irc.libera.chat over TLS
- **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.
- **Wi-Fi scan logs** (a CSV of the access points each scan sees) can be recorded from Wi-Fi Tools; monitoring-mode captures remain deferred.
- **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.
+18 -13
View File
@@ -36,7 +36,8 @@ std::string IrcConfig::validate() const {
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 &";
if (c.channel.size() < 2 || (c.channel[0] != '#' && c.channel[0] != '&'))
return "Auto-join: IRC channels start with # or &, each followed by its key if it has one";
return "";
}
@@ -56,23 +57,27 @@ std::string IrcConfig::save() {
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;
}
std::vector<IrcChannel> IrcConfig::parseChannels(const std::string& text) {
std::vector<IrcChannel> out;
std::string word;
auto take = [&]() {
if (word.empty()) return;
bool channel = word[0] == '#' || word[0] == '&';
if (!channel && !out.empty() && out.back().key.empty()) out.back().key = word;
else out.push_back({word, ""}); // a stray word fails validation as a channel
word.clear();
};
for (char c : text) {
if (c == ' ' || c == ',') take();
else word += c;
}
take();
return out;
}
std::string IrcConfig::formatChannels(const std::vector<std::string>& channels) {
std::string IrcConfig::formatChannels(const std::vector<IrcChannel>& channels) {
std::string out;
for (auto& c : channels) out += (out.empty() ? "" : " ") + c;
for (auto& c : channels) out += (out.empty() ? "" : ", ") + c.channel + (c.key.empty() ? "" : " " + c.key);
return out;
}
+10 -3
View File
@@ -7,6 +7,11 @@
namespace roro {
struct IrcChannel {
std::string channel; // "#roro"
std::string key; // empty when the IRC channel has none
};
// The one IRC server the IRC Service connects to, persisted in internal flash.
class IrcConfig {
public:
@@ -20,7 +25,7 @@ class IrcConfig {
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;
std::vector<IrcChannel> autojoin;
// A copy is a draft the UI can edit freely; copySettingsFrom() applies one.
IrcConfig(const IrcConfig&) = default;
@@ -41,8 +46,10 @@ class IrcConfig {
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 key, #public": a word starting with # or & is an IRC channel, the word after it
// (if it doesn't) its key. Spaces or commas separate entries, so "#a #b" also reads.
static std::vector<IrcChannel> parseChannels(const std::string& text);
static std::string formatChannels(const std::vector<IrcChannel>& channels);
private:
KeyValueStore& store_;
+61 -14
View File
@@ -37,6 +37,13 @@ std::pair<std::string, std::string> firstWord(const std::string& text) {
IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) {
buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""});
for (auto& c : config.autojoin)
if (!c.key.empty()) keys_[lower(c.channel)] = c.key;
}
void IrcSession::tick(uint32_t nowMs) {
nowMs_ = nowMs;
if (joinsHeld_ && nowMs - heldSinceMs_ >= kNickservWaitMs) joinChannels();
}
int IrcSession::totalUnread() const {
@@ -108,6 +115,8 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s
void IrcSession::connected(int64_t) {
registered_ = false;
joinsHeld_ = false;
saslFailed_ = false;
quit_ = false;
nick_ = config_.nick;
if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl");
@@ -130,18 +139,49 @@ 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}));
// NickServ when it's the configured login, or as the fallback when SASL failed.
// The account is named explicitly: we may be on a fallback nick if a stale session holds ours.
bool sasl = !config_.saslUser.empty() && !config_.saslPassword.empty();
std::string account = sasl ? config_.saslUser : config_.nick;
std::string identify;
if (!config_.nickservPassword.empty() && (!sasl || saslFailed_)) identify = account + " " + config_.nickservPassword;
else if (sasl && saslFailed_) identify = account + " " + config_.saslPassword;
if (!identify.empty()) {
// IRC channels for registered users only would refuse us until NickServ has logged us in.
send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + identify}));
joinsHeld_ = true;
heldSinceMs_ = nowMs_;
return;
}
joinChannels();
}
void IrcSession::joinChannels() {
joinsHeld_ = false;
std::vector<std::string> channels;
auto addOnce = [&](const std::string& c) {
for (auto& existing : channels)
if (lower(existing) == lower(c)) return;
channels.push_back(c);
};
for (auto& c : config_.autojoin) addOnce(c.channel);
for (auto& c : rejoin_) addOnce(c);
rejoin_.clear();
if (channels.empty()) return;
// JOIN #keyed,#open key: IRC pairs keys with the first channels listed.
std::string keyed, open, keys;
for (auto& c : channels) {
auto k = keys_.find(lower(c));
if (k != keys_.end()) {
keyed += (keyed.empty() ? "" : ",") + c;
keys += (keys.empty() ? "" : ",") + k->second;
} else {
open += (open.empty() ? "" : ",") + c;
}
}
std::string list = keyed + (!keyed.empty() && !open.empty() ? "," : "") + open;
send(keys.empty() ? IrcMessage::serialize("JOIN", {list}) : IrcMessage::serialize("JOIN", {list, keys}));
}
void IrcSession::receive(const std::string& raw, int64_t utc) {
@@ -163,11 +203,17 @@ void IrcSession::receive(const std::string& raw, int64_t utc) {
} else if (cmd == "AUTHENTICATE" && m.param(0) == "+") {
std::string user = config_.saslUser;
send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword));
} else if (cmd == "900") {
// Logged in. Take our nick back from a stale session, then join.
if (lower(nick_) != lower(config_.nick))
send(IrcMessage::serialize("PRIVMSG", {"NickServ", "REGAIN " + config_.nick}));
if (joinsHeld_) joinChannels();
} 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);
info(0, "SASL login failed: " + m.param(m.params.size() - 1) + " (trying NickServ instead)", utc);
saslFailed_ = true;
send("CAP END");
} else if (cmd == "PRIVMSG" || cmd == "NOTICE") {
onPrivmsg(m, utc, cmd == "NOTICE");
@@ -280,10 +326,11 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
const IrcBuffer& buf = buffers_[b];
bool inChannel = buf.type == IrcBuffer::Type::Channel;
if (verb == "join") {
if (verb == "join" || verb == "j") {
auto [channel, key] = firstWord(rest);
if (channel.empty()) return info(b, "Usage: /join #channel", utc);
if (!isChannel(channel)) channel = "#" + channel;
if (!key.empty()) keys_[lower(channel)] = key; // reused when rejoining
send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key}));
} else if (verb == "part") {
std::string channel = inChannel ? buf.name : "";
@@ -315,7 +362,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
} 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);
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc);
}
}
+13
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <deque>
#include <map>
#include <set>
#include <string>
#include <vector>
@@ -53,6 +54,12 @@ class IrcSession {
explicit IrcSession(const IrcConfig& config);
// Joins wait this long for NickServ to confirm a login before going ahead anyway.
static constexpr uint32_t kNickservWaitMs = 2000;
// Uptime in ms, called regularly: releases joins held back for NickServ.
void tick(uint32_t nowMs);
void connected(int64_t utc);
void disconnected(int64_t utc, const std::string& reason);
void receive(const std::string& raw, int64_t utc);
@@ -83,6 +90,7 @@ class IrcSession {
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 joinChannels();
void command(int buffer, const std::string& text, int64_t utc);
void say(int buffer, const std::string& text, int64_t utc, bool action);
@@ -94,6 +102,11 @@ class IrcSession {
int viewing_ = -1;
std::set<std::string> namesRequested_;
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
std::map<std::string, std::string> keys_; // lower-case IRC channel -> key, from config and /join
bool joinsHeld_ = false; // waiting for NickServ before joining
bool saslFailed_ = false;
uint32_t heldSinceMs_ = 0;
uint32_t nowMs_ = 0;
IrcEffects effects_;
uint32_t revision_ = 0;
};
+1 -1
View File
@@ -18,7 +18,7 @@ struct CleanupCategory {
};
inline const CleanupCategory kCleanupCategories[] = {
{"IRC logs", "/irc"},
{"Probe-request logs", "/wifi/probes"},
{"Wi-Fi scan logs", "/wifi/scans"},
{"Wi-Fi captures", "/captures/wifi"},
};
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <deque>
#include <string>
namespace roro {
// Lines already entered, browsed shell-style: up() goes back in time, down() forward, and past the
// newest line the draft being typed comes back.
class InputHistory {
public:
explicit InputHistory(size_t capacity) : capacity_(capacity) {}
void add(const std::string& line) {
browsing_ = -1;
if (line.empty() || (!lines_.empty() && lines_.back() == line)) return;
lines_.push_back(line);
if (lines_.size() > capacity_) lines_.pop_front();
}
// `current` is what the editor holds now (kept as the draft when browsing starts).
bool up(const std::string& current, std::string& out) {
if (lines_.empty() || browsing_ == 0) return false;
if (browsing_ < 0) {
draft_ = current;
browsing_ = static_cast<int>(lines_.size());
}
out = lines_[--browsing_];
return true;
}
bool down(std::string& out) {
if (browsing_ < 0) return false;
if (++browsing_ >= static_cast<int>(lines_.size())) {
browsing_ = -1;
out = draft_;
} else {
out = lines_[browsing_];
}
return true;
}
private:
size_t capacity_;
std::deque<std::string> lines_;
int browsing_ = -1; // index being shown, -1 when editing the draft
std::string draft_;
};
} // namespace roro
+59
View File
@@ -0,0 +1,59 @@
#include "network_list_view.h"
#include <algorithm>
#include <cctype>
namespace roro {
namespace {
std::string lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
} // namespace
std::vector<int> orderNetworks(const std::vector<ScanEntry>& entries, WifiSort sort, const NetworkFilter& filter) {
std::vector<int> idx;
for (int i = 0; i < static_cast<int>(entries.size()); i++) {
const ScanEntry& e = entries[i];
if (filter.openOnly && !e.open) continue;
if (filter.hideHidden && e.ssid.empty()) continue;
if (e.rssi < filter.minRssi) continue;
idx.push_back(i);
}
std::stable_sort(idx.begin(), idx.end(), [&](int a, int b) {
const ScanEntry& x = entries[a];
const ScanEntry& y = entries[b];
switch (sort) {
case WifiSort::Signal: return x.rssi > y.rssi; // strongest first
case WifiSort::Channel: return x.channel != y.channel ? x.channel < y.channel : x.rssi > y.rssi;
case WifiSort::Name: {
// Hidden networks (no name) sort to the end.
if (x.ssid.empty() != y.ssid.empty()) return y.ssid.empty();
return lower(x.ssid) < lower(y.ssid);
}
}
return false;
});
return idx;
}
const char* sortName(WifiSort sort) {
switch (sort) {
case WifiSort::Signal: return "signal";
case WifiSort::Channel: return "channel";
case WifiSort::Name: return "name";
}
return "";
}
WifiSort nextSort(WifiSort sort) {
switch (sort) {
case WifiSort::Signal: return WifiSort::Channel;
case WifiSort::Channel: return WifiSort::Name;
case WifiSort::Name: return WifiSort::Signal;
}
return WifiSort::Signal;
}
} // namespace roro
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <string>
#include <vector>
#include "scan_entry.h"
namespace roro {
enum class WifiSort { Signal, Channel, Name };
struct NetworkFilter {
bool openOnly = false;
bool hideHidden = false;
int minRssi = -100; // drop anything weaker
};
// The order and selection the network list shows: indices into `entries`, sorted and filtered.
// Stable, so equal rows keep their scan order.
std::vector<int> orderNetworks(const std::vector<ScanEntry>& entries, WifiSort sort, const NetworkFilter& filter);
const char* sortName(WifiSort sort);
WifiSort nextSort(WifiSort sort);
} // namespace roro
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
namespace roro {
// One access point an ordinary Wi-Fi scan reported (a network announcing itself). No client or
// monitor-mode data: just what a beacon broadcasts.
struct ScanEntry {
std::string ssid; // empty for a hidden network
std::string bssid; // the access point's own address, from its beacon
int channel;
int rssi;
bool open;
std::string security; // "open", "WPA2", "WPA3"...
};
} // namespace roro
+27
View File
@@ -0,0 +1,27 @@
#include "scan_log.h"
namespace roro::scanlog {
std::string csvField(const std::string& value) {
bool needsQuote = false;
for (char c : value)
if (c == ',' || c == '"' || c == '\n' || c == '\r') needsQuote = true;
if (!needsQuote) return value;
std::string out = "\"";
for (char c : value) {
if (c == '"') out += '"'; // escape by doubling
out += c;
}
out += '"';
return out;
}
std::string header() { return "time,bssid,channel,rssi,security,ssid"; }
std::string row(const std::string& timestamp, const ScanEntry& ap) {
return csvField(timestamp) + "," + csvField(ap.bssid) + "," + std::to_string(ap.channel) + "," +
std::to_string(ap.rssi) + "," + csvField(ap.security) + "," +
csvField(ap.ssid.empty() ? "(hidden)" : ap.ssid);
}
} // namespace roro::scanlog
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "scan_entry.h"
namespace roro {
// Writes what Wi-Fi Tools already sees (access points, channel, signal) to a CSV Log, so the user
// can track their own Wi-Fi environment over time. One row per access point per logged scan.
namespace scanlog {
// A CSV field, quoted when it contains a comma, quote or newline.
std::string csvField(const std::string& value);
// The header row (no trailing newline).
std::string header();
// One data row for an access point seen at `timestamp` (a preformatted local time string).
std::string row(const std::string& timestamp, const ScanEntry& ap);
} // namespace scanlog
// Decides when to write a scan to the Log: at most once per interval.
class ScanLogThrottle {
public:
explicit ScanLogThrottle(uint32_t intervalMs) : intervalMs_(intervalMs) {}
bool due(uint32_t nowMs) const { return !logged_ || nowMs - lastMs_ >= intervalMs_; }
void logged(uint32_t nowMs) {
lastMs_ = nowMs;
logged_ = true;
}
void reset() { logged_ = false; }
private:
uint32_t intervalMs_;
uint32_t lastMs_ = 0;
bool logged_ = false;
};
} // namespace roro
+15 -3
View File
@@ -80,13 +80,24 @@ bool IrcApp::onKey(const KeyEvent& e) {
}
bool IrcApp::onChatKey(const KeyEvent& e) {
// Alt + ; / Alt + . scroll the Buffer; Up / Down (Fn + ; / Fn + .) recall sent lines.
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
if (e.ch == ';') scroll_++; // older
else if (scroll_ > 0) scroll_--;
return true;
}
std::string recalled;
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::Up:
if (history_.up(input_.text(), recalled)) input_.setText(recalled);
break;
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: {
int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); });
view((current_ + 1) % count);
@@ -95,6 +106,7 @@ bool IrcApp::onChatKey(const KeyEvent& e) {
case Key::Select: {
std::string text = input_.text();
if (text.empty()) break;
history_.add(text);
input_.setText("");
scroll_ = 0;
if (lowered(text) == "/settings") {
@@ -294,7 +306,7 @@ void IrcApp::drawSettings(Canvas& c) {
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,
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4,
area.y + 44);
return;
}
+2
View File
@@ -5,6 +5,7 @@
#include "app.h"
#include "event_bus.h"
#include "input_history.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/irc_service.h"
@@ -48,6 +49,7 @@ class IrcApp : public App {
uint32_t seenRevision_ = 0;
IrcService::Status seenStatus_ = IrcService::Status::Stopped;
LineEditor input_{400};
InputHistory history_{30};
ListModel fields_{theme::kContent.h / theme::kLineHeight};
bool editing_ = false;
LineEditor fieldEditor_{63};
+84 -36
View File
@@ -14,6 +14,7 @@ namespace {
const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"};
constexpr uint32_t kListRescanMs = 4000;
constexpr uint32_t kTrackRescanMs = 400;
constexpr int kWeakRssi = -75;
} // namespace
void WifiToolsApp::onEnter() {
@@ -21,7 +22,10 @@ void WifiToolsApp::onEnter() {
menu_.setCount(3);
}
void WifiToolsApp::onExit() { wifi_.endListScans(); }
void WifiToolsApp::onExit() {
logging_ = false; // logging only runs while this foreground App is scanning
wifi_.endListScans();
}
void WifiToolsApp::open(View v) {
view_ = v;
@@ -29,10 +33,9 @@ void WifiToolsApp::open(View v) {
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;
void WifiToolsApp::refreshOrder() {
order_ = orderNetworks(wifi_.listScan(), sort_, filter_);
networks_.setCount(static_cast<int>(order_.size()));
}
bool WifiToolsApp::onKey(const KeyEvent& e) {
@@ -42,36 +45,42 @@ bool WifiToolsApp::onKey(const KeyEvent& e) {
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;
case Key::Select: open(menu_.selected() == 1 ? View::Channels : View::Networks); 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::Up: networks_.up(); return true;
case Key::Down: networks_.down(); return true;
case Key::Back: view_ = View::Menu; return true;
case Key::Select: {
auto list = sortedScan();
if (networks_.selected() < 0 || networks_.selected() >= static_cast<int>(list.size())) break;
const ScanEntry& ap = list[networks_.selected()];
if (networks_.selected() < 0 || networks_.selected() >= static_cast<int>(order_.size())) return true;
const ScanEntry& ap = wifi_.listScan()[order_[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;
return true;
}
default: break;
case Key::Char:
switch (e.ch) {
case 's': sort_ = nextSort(sort_); break;
case 'o': filter_.openOnly = !filter_.openOnly; break;
case 'h': filter_.hideHidden = !filter_.hideHidden; break;
case 'w': filter_.minRssi = filter_.minRssi == kWeakRssi ? -100 : kWeakRssi; break;
case 'l':
logging_ = !logging_;
logThrottle_.reset();
if (logging_) loggedRows_ = 0;
break;
default: return true;
}
refreshOrder();
return true;
default: return true;
}
return true;
case View::Channels:
if (e.key == Key::Back) view_ = View::Menu;
return true;
@@ -83,6 +92,23 @@ bool WifiToolsApp::onKey(const KeyEvent& e) {
return false;
}
void WifiToolsApp::logScan(uint32_t nowMs) {
std::string date = clock_.localDate();
if (date.empty()) return; // no timestamp without a clock
if (!logThrottle_.due(nowMs)) return;
std::string path = "/wifi/scans/" + date + ".csv";
if (date != headerDate_) {
storage_.appendLine(path, scanlog::header());
headerDate_ = date;
}
std::string stamp = date + " " + clock_.displayTime();
for (auto& ap : wifi_.listScan()) {
storage_.appendLine(path, scanlog::row(stamp, ap));
loggedRows_++;
}
logThrottle_.logged(nowMs);
}
void WifiToolsApp::update(uint32_t nowMs) {
if (view_ == View::Menu) return;
@@ -90,23 +116,22 @@ void WifiToolsApp::update(uint32_t nowMs) {
seenSeq_ = wifi_.listScanSeq();
if (view_ == View::Tracker) {
bool found = false;
for (auto& ap : wifi_.listScan()) {
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()));
refreshOrder();
if (logging_) logScan(nowMs);
}
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);
}
@@ -129,17 +154,43 @@ void WifiToolsApp::draw(Canvas& c) {
}
void WifiToolsApp::drawNetworks(Canvas& c) {
auto list = sortedScan();
if (list.empty()) {
widgets::textLines(c, {wifi_.listScanDone() ? "No networks found yet." : "Scanning..."}, 0, theme::kContent);
const auto& area = theme::kContent;
const auto& scan = wifi_.listScan();
// Status line: sort, filters, logging.
c.setFont(&fonts::small);
std::string top = std::string("sort:") + sortName(sort_);
if (filter_.openOnly) top += " open";
if (filter_.hideHidden) top += " !hid";
if (filter_.minRssi != -100) top += " strong";
c.setTextColor(theme::kMuted);
c.drawString(top.c_str(), 4, area.y + 1);
if (logging_) {
c.setTextColor(theme::kWarning);
c.setTextDatum(top_right);
c.drawString(("LOG " + std::to_string(loggedRows_)).c_str(), area.w - 4, area.y + 1);
c.setTextDatum(top_left);
}
theme::Rect list{area.x, area.y + 13, area.w, area.h - 13};
if (order_.empty()) {
bool any = !scan.empty();
widgets::textLines(c,
{wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.")
: "Scanning...",
"", "s sort o open h hide-hidden w strong l log"},
0, list);
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; },
c, networks_, list,
[&](int i) {
return "ch" + std::to_string(list[i].channel) + " " + std::to_string(list[i].rssi) + " " + list[i].security;
const ScanEntry& ap = scan[order_[i]];
return ap.ssid.empty() ? std::string("(hidden)") : ap.ssid;
},
[&](int i) {
const ScanEntry& ap = scan[order_[i]];
return "ch" + std::to_string(ap.channel) + " " + std::to_string(ap.rssi) + " " + ap.security;
});
}
@@ -185,7 +236,6 @@ void WifiToolsApp::drawTracker(Canvas& c) {
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);
@@ -193,13 +243,11 @@ void WifiToolsApp::drawTracker(Canvas& c) {
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());
+23 -5
View File
@@ -5,17 +5,23 @@
#include "app.h"
#include "list_model.h"
#include "network_list_view.h"
#include "scan_log.h"
#include "services/clock_service.h"
#include "services/storage_service.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.
// Wi-Fi diagnostics from ordinary scans: nearby access points (sortable, filterable, optionally
// logged to CSV), how crowded each channel is, and a signal tracker. None of it interrupts the
// connection or IRC, and none of it captures anyone's traffic.
class WifiToolsApp : public App {
public:
explicit WifiToolsApp(WifiService& wifi) : wifi_(wifi) {}
WifiToolsApp(WifiService& wifi, StorageService& storage, ClockService& clock)
: wifi_(wifi), storage_(storage), clock_(clock) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
@@ -26,18 +32,30 @@ class WifiToolsApp : public App {
enum class View { Menu, Networks, Channels, Tracker };
void open(View v);
void refreshOrder();
void logScan(uint32_t nowMs);
void drawNetworks(Canvas& c);
void drawChannels(Canvas& c);
void drawTracker(Canvas& c);
std::vector<ScanEntry> sortedScan() const;
WifiService& wifi_;
StorageService& storage_;
ClockService& clock_;
View view_ = View::Menu;
ListModel menu_{theme::kContent.h / theme::kLineHeight};
ListModel networks_{theme::kContent.h / theme::kLineHeight};
ListModel networks_{(theme::kContent.h - 14) / theme::kLineHeight};
std::vector<int> order_; // indices into the scan, after sort and filter
WifiSort sort_ = WifiSort::Signal;
NetworkFilter filter_;
uint32_t seenSeq_ = 0;
uint32_t nextScanMs_ = 0;
// Scan logging
bool logging_ = false;
ScanLogThrottle logThrottle_{30000};
std::string headerDate_;
uint32_t loggedRows_ = 0;
// Tracker
std::string targetBssid_, targetName_;
int targetChannel_ = 0;
+21 -3
View File
@@ -1,5 +1,6 @@
// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
#include <M5Cardputer.h>
#include <SD.h>
#include "app_manager.h"
#include "apps/demo_app.h"
@@ -116,7 +117,7 @@ void setup() {
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({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
@@ -163,7 +164,8 @@ static void serialCommands() {
String k = line.substring(4);
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select;
if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key));
KeyEvent ev = k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key);
if (!power->onKey(millis())) apps->handleKey(ev);
}
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t');
@@ -178,6 +180,18 @@ static void serialCommands() {
storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str());
Serial.printf("log: queued to %s\n", path.c_str());
}
if (line.startsWith("cat ")) {
std::string path = line.substring(4).c_str();
File f = SD.open(path.c_str());
if (!f) Serial.printf("cat: cannot open %s\n", path.c_str());
else {
Serial.printf("--- %s (%u B) ---\n", path.c_str(), (unsigned)f.size());
int shown = 0;
while (f.available() && shown < 1200) { Serial.write(f.read()); shown++; }
Serial.println("\n--- end ---");
f.close();
}
}
if (line == "sd list") {
storageService->requestListing();
listingWanted = true;
@@ -193,11 +207,15 @@ static void serialCommands() {
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());
IrcConfig c = irc->draftConfig(); // which login is configured, never the secrets
Serial.printf("irc: nick %s, sasl %s, nickserv %s, autojoin %u channels\n", c.nick.c_str(),
c.saslUser.empty() || c.saslPassword.empty() ? "off" : "on",
c.nickservPassword.empty() ? "off" : "on", (unsigned)c.autojoin.size());
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;
size_t from = 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());
}
+4
View File
@@ -229,6 +229,10 @@ void IrcService::loop() {
pingSent_ = true;
}
}
{
Lock l(lock_);
session_->tick(millis());
}
flushEffects();
vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250));
}
+1 -9
View File
@@ -4,6 +4,7 @@
#include "event_bus.h"
#include "saved_networks.h"
#include "scan_entry.h"
#include "service.h"
#include "services/clock_service.h"
#include "settings.h"
@@ -11,15 +12,6 @@
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 {
@@ -0,0 +1,73 @@
#include <unity.h>
#include "input_history.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_up_brings_back_previous_lines_newest_first() {
InputHistory h(10);
h.add("one");
h.add("two");
std::string line;
TEST_ASSERT_TRUE(h.up("", line));
TEST_ASSERT_EQUAL_STRING("two", line.c_str());
TEST_ASSERT_TRUE(h.up("two", line));
TEST_ASSERT_EQUAL_STRING("one", line.c_str());
TEST_ASSERT_FALSE(h.up("one", line)); // nothing older
}
void test_down_returns_to_the_draft_being_typed() {
InputHistory h(10);
h.add("one");
std::string line;
h.up("half typed", line);
TEST_ASSERT_TRUE(h.down(line));
TEST_ASSERT_EQUAL_STRING("half typed", line.c_str());
TEST_ASSERT_FALSE(h.down(line)); // already at the draft
}
void test_adding_resets_browsing_and_skips_repeats_and_empty_lines() {
InputHistory h(10);
h.add("same");
h.add("same");
h.add("");
std::string line;
h.up("", line);
TEST_ASSERT_FALSE(h.up(line, line)); // only one entry kept
h.add("next");
TEST_ASSERT_TRUE(h.up("", line));
TEST_ASSERT_EQUAL_STRING("next", line.c_str());
}
void test_keeps_only_the_most_recent_lines() {
InputHistory h(2);
h.add("a");
h.add("b");
h.add("c");
std::string line;
h.up("", line);
h.up(line, line);
TEST_ASSERT_EQUAL_STRING("b", line.c_str());
TEST_ASSERT_FALSE(h.up(line, line));
}
void test_empty_history_does_nothing() {
InputHistory h(5);
std::string line = "draft";
TEST_ASSERT_FALSE(h.up("draft", line));
TEST_ASSERT_FALSE(h.down(line));
TEST_ASSERT_EQUAL_STRING("draft", line.c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_up_brings_back_previous_lines_newest_first);
RUN_TEST(test_down_returns_to_the_draft_being_typed);
RUN_TEST(test_adding_resets_browsing_and_skips_repeats_and_empty_lines);
RUN_TEST(test_keeps_only_the_most_recent_lines);
RUN_TEST(test_empty_history_does_nothing);
return UNITY_END();
}
+27 -6
View File
@@ -28,7 +28,7 @@ void test_save_and_reload() {
c.nick = "clement";
c.saslUser = "clement";
c.saslPassword = "s3cret";
c.autojoin = {"#roro", "#meshtastic"};
c.autojoin = {{"#roro", ""}, {"#meshtastic", "k3y"}};
TEST_ASSERT_TRUE(c.save().empty());
}
IrcConfig again(store);
@@ -36,7 +36,8 @@ void test_save_and_reload() {
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());
TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].channel.c_str());
TEST_ASSERT_EQUAL_STRING("k3y", again.autojoin[1].key.c_str());
}
void test_invalid_values_are_refused_with_a_reason() {
@@ -54,14 +55,32 @@ void test_invalid_values_are_refused_with_a_reason() {
c.host = "";
TEST_ASSERT_FALSE(c.save().empty());
c.host = "irc.libera.chat";
c.autojoin = {"roro"}; // IRC channels start with # or &
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());
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); // the older, space-separated form
TEST_ASSERT_EQUAL_STRING("#a, #b", IrcConfig::formatChannels({{"#a", ""}, {"#b", ""}}).c_str());
}
void test_autojoin_entries_can_carry_a_key() {
auto list = IrcConfig::parseChannels("#private s3cret, #public &local");
TEST_ASSERT_EQUAL(3, list.size());
TEST_ASSERT_EQUAL_STRING("#private", list[0].channel.c_str());
TEST_ASSERT_EQUAL_STRING("s3cret", list[0].key.c_str());
TEST_ASSERT_EQUAL_STRING("", list[1].key.c_str());
TEST_ASSERT_EQUAL_STRING("&local", list[2].channel.c_str());
TEST_ASSERT_EQUAL_STRING("#private s3cret, #public, &local", IrcConfig::formatChannels(list).c_str());
}
void test_a_key_without_a_channel_is_refused() {
MemoryStore store;
IrcConfig c(store);
c.load("x");
c.autojoin = IrcConfig::parseChannels("orphan #a");
TEST_ASSERT_FALSE(c.validate().empty());
}
void test_copy_settings_from_a_draft() {
@@ -70,7 +89,7 @@ void test_copy_settings_from_a_draft() {
live.load("x");
IrcConfig draft = live;
draft.host = "irc.example.org";
draft.autojoin = {"#a"};
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());
@@ -84,5 +103,7 @@ int main() {
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);
RUN_TEST(test_autojoin_entries_can_carry_a_key);
RUN_TEST(test_a_key_without_a_channel_is_refused);
return UNITY_END();
}
+137 -5
View File
@@ -20,7 +20,7 @@ struct Fixture {
explicit Fixture(bool sasl = false) {
config.load("roro");
config.nick = "roro";
config.autojoin = {"#roro"};
config.autojoin = {{"#roro", ""}};
if (sasl) {
config.saslUser = "acct";
config.saslPassword = "pw";
@@ -28,6 +28,7 @@ struct Fixture {
session.reset(new IrcSession(config));
}
void recv(const std::string& line) { session->receive(line, 1000); }
void tick(uint32_t ms) { session->tick(ms); }
IrcEffects& take() {
fx = session->takeEffects();
return fx;
@@ -65,15 +66,127 @@ void test_welcome_registers_and_joins_autojoin_channels() {
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
}
void test_nickserv_identify_after_welcome() {
void test_nickserv_identify_after_welcome_and_joins_wait_for_it() {
Fixture f;
f.config.nickservPassword = "pw";
f.session.reset(new IrcSession(f.config));
f.registerNow();
f.session->connected(0);
f.tick(0);
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw"));
TEST_ASSERT_FALSE(f.sent("JOIN #roro")); // not before NickServ has had its say
f.tick(1000);
TEST_ASSERT_TRUE(f.take().send.empty());
}
void test_joins_follow_the_logged_in_reply() {
Fixture f;
f.config.nickservPassword = "pw";
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.tick(0);
f.recv(":srv 001 roro :Welcome");
f.take();
f.tick(300);
f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro");
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
f.tick(5000);
TEST_ASSERT_TRUE(f.take().send.empty()); // and only once
}
void test_joins_go_ahead_after_two_seconds_without_a_reply() {
Fixture f;
f.config.nickservPassword = "pw";
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.tick(10000);
f.recv(":srv 001 roro :Welcome");
f.take();
f.tick(11999);
TEST_ASSERT_TRUE(f.take().send.empty());
f.tick(12000);
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
}
void test_failed_sasl_falls_back_to_nickserv_and_holds_joins() {
Fixture f(true);
f.config.nickservPassword = "nspw";
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.recv(":srv CAP * ACK :sasl");
f.recv("AUTHENTICATE +");
f.recv(":srv 904 roro :SASL authentication failed");
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw"));
TEST_ASSERT_FALSE(f.sent("JOIN #roro"));
f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro");
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
}
void test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account() {
Fixture f(true); // SASL user "acct", password "pw"
f.session->connected(0);
f.recv(":srv 904 roro :SASL authentication failed");
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct pw"));
}
void test_successful_sasl_joins_at_once() {
Fixture f(true);
f.config.nickservPassword = "nspw";
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.recv(":srv 903 roro :SASL authentication successful");
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
TEST_ASSERT_FALSE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw"));
}
void test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it() {
Fixture f;
f.config.nickservPassword = "pw";
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.recv(":srv 433 * roro :Nickname is already in use"); // a stale session still holds it
f.recv(":srv 001 roro_ :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw"));
f.recv(":srv 900 roro_ roro_!u@h roro :You are now logged in as roro");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :REGAIN roro"));
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
}
void test_autojoin_sends_keyed_channels_first_with_their_keys() {
Fixture f;
f.config.autojoin = IrcConfig::parseChannels("#public, #private s3cret");
f.session.reset(new IrcSession(f.config));
f.session->connected(0);
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw"));
TEST_ASSERT_TRUE(f.sent("JOIN #private,#public s3cret"));
}
void test_keys_used_with_join_are_reused_on_rejoin() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.session->input(1, "/join #vault k3y", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #vault k3y"));
f.recv(":roro!u@h JOIN #vault");
f.session->disconnected(0, "Wi-Fi lost");
f.session->connected(0);
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #vault,#roro k3y"));
}
void test_sasl_plain_flow() {
@@ -253,6 +366,16 @@ void test_input_message_and_commands() {
TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str());
}
void test_j_is_short_for_join() {
Fixture f;
f.registerNow();
f.session->input(0, "/j #short", 0);
f.session->input(0, "/j #locked k3y", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #short"));
TEST_ASSERT_TRUE(f.sent("JOIN #locked k3y"));
}
void test_msg_opens_a_query_buffer() {
Fixture f;
f.registerNow();
@@ -347,7 +470,15 @@ 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_nickserv_identify_after_welcome_and_joins_wait_for_it);
RUN_TEST(test_joins_follow_the_logged_in_reply);
RUN_TEST(test_joins_go_ahead_after_two_seconds_without_a_reply);
RUN_TEST(test_failed_sasl_falls_back_to_nickserv_and_holds_joins);
RUN_TEST(test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account);
RUN_TEST(test_successful_sasl_joins_at_once);
RUN_TEST(test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it);
RUN_TEST(test_autojoin_sends_keyed_channels_first_with_their_keys);
RUN_TEST(test_keys_used_with_join_are_reused_on_rejoin);
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);
@@ -362,6 +493,7 @@ int main() {
RUN_TEST(test_action_and_ctcp_version);
RUN_TEST(test_topic_is_kept);
RUN_TEST(test_input_message_and_commands);
RUN_TEST(test_j_is_short_for_join);
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);
@@ -0,0 +1,87 @@
#include <unity.h>
#include "network_list_view.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static std::vector<ScanEntry> sample() {
return {
{"beta", "b", 6, -70, false, "WPA2"},
{"alpha", "a", 11, -50, true, "open"},
{"", "h", 1, -60, false, "WPA2"}, // hidden
{"Gamma", "g", 6, -45, false, "WPA3"},
};
}
static std::vector<std::string> names(const std::vector<ScanEntry>& e, const std::vector<int>& idx) {
std::vector<std::string> out;
for (int i : idx) out.push_back(e[i].ssid);
return out;
}
void test_sort_by_signal_strongest_first() {
auto e = sample();
auto idx = orderNetworks(e, WifiSort::Signal, {});
TEST_ASSERT_EQUAL(4, idx.size());
TEST_ASSERT_EQUAL_STRING("Gamma", e[idx[0]].ssid.c_str()); // -45
TEST_ASSERT_EQUAL_STRING("alpha", e[idx[1]].ssid.c_str()); // -50
TEST_ASSERT_EQUAL_STRING("beta", e[idx[3]].ssid.c_str()); // -70
}
void test_sort_by_channel_then_signal() {
auto e = sample();
auto idx = orderNetworks(e, WifiSort::Channel, {});
TEST_ASSERT_EQUAL(1, e[idx[0]].channel); // channel 1 first
TEST_ASSERT_EQUAL(6, e[idx[1]].channel); // then channel 6...
TEST_ASSERT_EQUAL_STRING("Gamma", e[idx[1]].ssid.c_str()); // -45 beats -70 on ch 6
TEST_ASSERT_EQUAL(11, e[idx[3]].channel);
}
void test_sort_by_name_case_insensitive_hidden_last() {
auto e = sample();
auto idx = orderNetworks(e, WifiSort::Name, {});
auto n = names(e, idx);
TEST_ASSERT_EQUAL_STRING("alpha", n[0].c_str());
TEST_ASSERT_EQUAL_STRING("beta", n[1].c_str());
TEST_ASSERT_EQUAL_STRING("Gamma", n[2].c_str());
TEST_ASSERT_EQUAL_STRING("", n[3].c_str()); // hidden last
}
void test_filter_open_only() {
auto e = sample();
auto idx = orderNetworks(e, WifiSort::Signal, {true, false, -100});
TEST_ASSERT_EQUAL(1, idx.size());
TEST_ASSERT_EQUAL_STRING("alpha", e[idx[0]].ssid.c_str());
}
void test_filter_hide_hidden_and_weak() {
auto e = sample();
NetworkFilter f;
f.hideHidden = true;
f.minRssi = -60;
auto idx = orderNetworks(e, WifiSort::Signal, f);
// keeps Gamma(-45), alpha(-50); drops beta(-70) and the hidden(-60 is not < -60, but hidden)
for (int i : idx) TEST_ASSERT_FALSE(e[i].ssid.empty());
TEST_ASSERT_EQUAL(2, idx.size());
}
void test_sort_cycles() {
TEST_ASSERT_EQUAL(static_cast<int>(WifiSort::Channel), static_cast<int>(nextSort(WifiSort::Signal)));
TEST_ASSERT_EQUAL(static_cast<int>(WifiSort::Name), static_cast<int>(nextSort(WifiSort::Channel)));
TEST_ASSERT_EQUAL(static_cast<int>(WifiSort::Signal), static_cast<int>(nextSort(WifiSort::Name)));
TEST_ASSERT_EQUAL_STRING("signal", sortName(WifiSort::Signal));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_sort_by_signal_strongest_first);
RUN_TEST(test_sort_by_channel_then_signal);
RUN_TEST(test_sort_by_name_case_insensitive_hidden_last);
RUN_TEST(test_filter_open_only);
RUN_TEST(test_filter_hide_hidden_and_weak);
RUN_TEST(test_sort_cycles);
return UNITY_END();
}
+68
View File
@@ -0,0 +1,68 @@
#include <unity.h>
#include "scan_log.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static ScanEntry ap(std::string ssid, std::string bssid, int ch, int rssi, std::string sec) {
return {ssid, bssid, ch, rssi, sec == "open", sec};
}
void test_plain_fields_are_unquoted() {
TEST_ASSERT_EQUAL_STRING("home", scanlog::csvField("home").c_str());
TEST_ASSERT_EQUAL_STRING("WPA2", scanlog::csvField("WPA2").c_str());
}
void test_fields_with_commas_or_quotes_are_quoted() {
TEST_ASSERT_EQUAL_STRING("\"a,b\"", scanlog::csvField("a,b").c_str());
TEST_ASSERT_EQUAL_STRING("\"say \"\"hi\"\"\"", scanlog::csvField("say \"hi\"").c_str());
}
void test_header_and_row() {
TEST_ASSERT_EQUAL_STRING("time,bssid,channel,rssi,security,ssid", scanlog::header().c_str());
auto r = scanlog::row("2026-10-03 20:45:01", ap("home", "aa:bb:cc:dd:ee:ff", 6, -55, "WPA2"));
TEST_ASSERT_EQUAL_STRING("2026-10-03 20:45:01,aa:bb:cc:dd:ee:ff,6,-55,WPA2,home", r.c_str());
}
void test_hidden_network_row() {
auto r = scanlog::row("t", ap("", "aa:bb:cc:dd:ee:ff", 1, -70, "WPA2"));
TEST_ASSERT_TRUE(r.find(",(hidden)") != std::string::npos);
}
void test_ssid_with_a_comma_is_quoted_in_the_row() {
auto r = scanlog::row("t", ap("cafe, bar", "aa:bb", 11, -40, "open"));
TEST_ASSERT_TRUE(r.find("\"cafe, bar\"") != std::string::npos);
}
void test_throttle_logs_once_per_interval() {
ScanLogThrottle t(30000);
TEST_ASSERT_TRUE(t.due(0)); // first time
t.logged(1000);
TEST_ASSERT_FALSE(t.due(30999));
TEST_ASSERT_TRUE(t.due(31000));
t.logged(31000);
TEST_ASSERT_FALSE(t.due(40000));
}
void test_reset_makes_it_due_again() {
ScanLogThrottle t(30000);
t.logged(1000);
TEST_ASSERT_FALSE(t.due(2000));
t.reset();
TEST_ASSERT_TRUE(t.due(2000));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plain_fields_are_unquoted);
RUN_TEST(test_fields_with_commas_or_quotes_are_quoted);
RUN_TEST(test_header_and_row);
RUN_TEST(test_hidden_network_row);
RUN_TEST(test_ssid_with_a_comma_is_quoted_in_the_row);
RUN_TEST(test_throttle_logs_once_per_interval);
RUN_TEST(test_reset_makes_it_due_again);
return UNITY_END();
}