Wi-Fi Tools: scan logging to CSV, plus sort and filter

- lib/wifi (host-tested): scan_log (CSV field quoting, header/row, a
  once-per-interval throttle) and network_list_view (sort by signal /
  channel / name, filter open-only / hide-hidden / strong-only)
- ScanEntry moved to lib/wifi so both are testable on the PC
- Wi-Fi Tools networks view: s sort, o/h/w filters, l toggles logging
  to /wifi/scans/<date>.csv (header + one row per AP per logged scan,
  throttled 30 s, needs the clock for timestamps); foreground-only
- Wi-Fi scan logs replace probe-request logs as a clean-up category
- Serial: cat <path>, and key <char> injects a character

Verified on the device: CSV written with header, timestamps, BSSID,
channel, RSSI, security and SSID; hidden networks marked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-03 20:52:43 +02:00
co-authored by Claude Opus 4.8
parent 5ac6afa557
commit 28d025fb13
15 changed files with 456 additions and 56 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 _Avoid_: modifier, alt
**Log**: **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 _Avoid_: history file, dump
**Capture**: **Capture**:
+2 -1
View File
@@ -44,12 +44,13 @@ sudo usermod -aG dialout "$USER"
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `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) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `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`) | | `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 | | `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 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 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 | | `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. - **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. - **The frame buffer is 8-bit colour** (Q46), applied as soon as IRC on TLS measured a 51 KB low.
- **Not done, carried over:** - **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. - **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:** - **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. - With Wi-Fi and IRC on TLS: about 92 KB free heap, about 77–81 KB at the lowest. The floor was 40 KB.
+1 -1
View File
@@ -18,7 +18,7 @@ struct CleanupCategory {
}; };
inline const CleanupCategory kCleanupCategories[] = { inline const CleanupCategory kCleanupCategories[] = {
{"IRC logs", "/irc"}, {"IRC logs", "/irc"},
{"Probe-request logs", "/wifi/probes"}, {"Wi-Fi scan logs", "/wifi/scans"},
{"Wi-Fi captures", "/captures/wifi"}, {"Wi-Fi captures", "/captures/wifi"},
}; };
+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
+85 -37
View File
@@ -14,6 +14,7 @@ namespace {
const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"}; const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"};
constexpr uint32_t kListRescanMs = 4000; constexpr uint32_t kListRescanMs = 4000;
constexpr uint32_t kTrackRescanMs = 400; constexpr uint32_t kTrackRescanMs = 400;
constexpr int kWeakRssi = -75;
} // namespace } // namespace
void WifiToolsApp::onEnter() { void WifiToolsApp::onEnter() {
@@ -21,7 +22,10 @@ void WifiToolsApp::onEnter() {
menu_.setCount(3); 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) { void WifiToolsApp::open(View v) {
view_ = v; view_ = v;
@@ -29,10 +33,9 @@ void WifiToolsApp::open(View v) {
requestRedraw(); requestRedraw();
} }
std::vector<ScanEntry> WifiToolsApp::sortedScan() const { void WifiToolsApp::refreshOrder() {
auto list = wifi_.listScan(); order_ = orderNetworks(wifi_.listScan(), sort_, filter_);
std::sort(list.begin(), list.end(), [](const ScanEntry& a, const ScanEntry& b) { return a.rssi > b.rssi; }); networks_.setCount(static_cast<int>(order_.size()));
return list;
} }
bool WifiToolsApp::onKey(const KeyEvent& e) { bool WifiToolsApp::onKey(const KeyEvent& e) {
@@ -42,36 +45,42 @@ bool WifiToolsApp::onKey(const KeyEvent& e) {
switch (e.key) { switch (e.key) {
case Key::Up: menu_.up(); return true; case Key::Up: menu_.up(); return true;
case Key::Down: menu_.down(); return true; case Key::Down: menu_.down(); return true;
case Key::Select: case Key::Select: open(menu_.selected() == 1 ? View::Channels : View::Networks); return true;
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; default: return false;
} }
case View::Networks: case View::Networks:
switch (e.key) { switch (e.key) {
case Key::Up: networks_.up(); break; case Key::Up: networks_.up(); return true;
case Key::Down: networks_.down(); break; case Key::Down: networks_.down(); return true;
case Key::Back: view_ = View::Menu; break; case Key::Back: view_ = View::Menu; return true;
case Key::Select: { case Key::Select: {
auto list = sortedScan(); if (networks_.selected() < 0 || networks_.selected() >= static_cast<int>(order_.size())) return true;
if (networks_.selected() < 0 || networks_.selected() >= static_cast<int>(list.size())) break; const ScanEntry& ap = wifi_.listScan()[order_[networks_.selected()]];
const ScanEntry& ap = list[networks_.selected()];
targetBssid_ = ap.bssid; targetBssid_ = ap.bssid;
targetName_ = ap.ssid.empty() ? "(hidden network)" : ap.ssid; targetName_ = ap.ssid.empty() ? "(hidden network)" : ap.ssid;
targetChannel_ = ap.channel; targetChannel_ = ap.channel;
tracker_ = SignalTracker(110); tracker_ = SignalTracker(110);
tracker_.seen(ap.rssi, millis()); tracker_.seen(ap.rssi, millis());
open(View::Tracker); open(View::Tracker);
break;
}
default: break;
}
return true; return true;
}
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;
}
case View::Channels: case View::Channels:
if (e.key == Key::Back) view_ = View::Menu; if (e.key == Key::Back) view_ = View::Menu;
return true; return true;
@@ -83,6 +92,23 @@ bool WifiToolsApp::onKey(const KeyEvent& e) {
return false; 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) { void WifiToolsApp::update(uint32_t nowMs) {
if (view_ == View::Menu) return; if (view_ == View::Menu) return;
@@ -90,23 +116,22 @@ void WifiToolsApp::update(uint32_t nowMs) {
seenSeq_ = wifi_.listScanSeq(); seenSeq_ = wifi_.listScanSeq();
if (view_ == View::Tracker) { if (view_ == View::Tracker) {
bool found = false; bool found = false;
for (auto& ap : wifi_.listScan()) { for (auto& ap : wifi_.listScan())
if (ap.bssid == targetBssid_) { if (ap.bssid == targetBssid_) {
tracker_.seen(ap.rssi, nowMs); tracker_.seen(ap.rssi, nowMs);
targetChannel_ = ap.channel; targetChannel_ = ap.channel;
found = true; found = true;
} }
}
if (!found) tracker_.missed(); if (!found) tracker_.missed();
} else { } else {
networks_.setCount(static_cast<int>(wifi_.listScan().size())); refreshOrder();
if (logging_) logScan(nowMs);
} }
requestRedraw(); requestRedraw();
} }
if (wifi_.listScanDone() && static_cast<int32_t>(nowMs - nextScanMs_) >= 0) { if (wifi_.listScanDone() && static_cast<int32_t>(nowMs - nextScanMs_) >= 0) {
bool tracking = view_ == View::Tracker; 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); wifi_.startListScan(tracking && !tracker_.lost(nowMs) ? targetChannel_ : 0);
nextScanMs_ = nowMs + (tracking ? kTrackRescanMs : kListRescanMs); nextScanMs_ = nowMs + (tracking ? kTrackRescanMs : kListRescanMs);
} }
@@ -129,17 +154,43 @@ void WifiToolsApp::draw(Canvas& c) {
} }
void WifiToolsApp::drawNetworks(Canvas& c) { void WifiToolsApp::drawNetworks(Canvas& c) {
auto list = sortedScan(); const auto& area = theme::kContent;
if (list.empty()) { const auto& scan = wifi_.listScan();
widgets::textLines(c, {wifi_.listScanDone() ? "No networks found yet." : "Scanning..."}, 0, theme::kContent);
// 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; return;
} }
if (networks_.count() != static_cast<int>(list.size())) networks_.setCount(static_cast<int>(list.size()));
widgets::list( widgets::list(
c, networks_, theme::kContent, c, networks_, list,
[&](int i) { return list[i].ssid.empty() ? std::string("(hidden)") : list[i].ssid; },
[&](int i) { [&](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"); std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " m: clicks " + (clicks_ ? "on" : "off");
c.drawString(sub.c_str(), 4, area.y + 17); c.drawString(sub.c_str(), 4, area.y + 17);
// Big reading
c.setFont(&fonts::bold); c.setFont(&fonts::bold);
c.setTextSize(2); c.setTextSize(2);
c.setTextColor(lost ? theme::kWarning : theme::kText); 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.drawString(reading.c_str(), 4, area.y + 30);
c.setTextSize(1); 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 strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60));
int barW = area.w - 8; int barW = area.w - 8;
c.drawRect(4, area.y + 60, barW, 8, theme::kMuted); c.drawRect(4, area.y + 60, barW, 8, theme::kMuted);
c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kMessage); 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 int gy = area.y + 74, gh = area.h - 76;
const auto& h = tracker_.history(); const auto& h = tracker_.history();
int n = static_cast<int>(h.size()); int n = static_cast<int>(h.size());
+23 -5
View File
@@ -5,17 +5,23 @@
#include "app.h" #include "app.h"
#include "list_model.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 "services/wifi_service.h"
#include "signal_tracker.h" #include "signal_tracker.h"
#include "ui/theme.h" #include "ui/theme.h"
namespace roro { namespace roro {
// Wi-Fi diagnostics from ordinary scans: nearby access points, how crowded each channel is, and // Wi-Fi diagnostics from ordinary scans: nearby access points (sortable, filterable, optionally
// a signal tracker for finding one access point. None of it interrupts the connection or IRC. // 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 { class WifiToolsApp : public App {
public: public:
explicit WifiToolsApp(WifiService& wifi) : wifi_(wifi) {} WifiToolsApp(WifiService& wifi, StorageService& storage, ClockService& clock)
: wifi_(wifi), storage_(storage), clock_(clock) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
@@ -26,18 +32,30 @@ class WifiToolsApp : public App {
enum class View { Menu, Networks, Channels, Tracker }; enum class View { Menu, Networks, Channels, Tracker };
void open(View v); void open(View v);
void refreshOrder();
void logScan(uint32_t nowMs);
void drawNetworks(Canvas& c); void drawNetworks(Canvas& c);
void drawChannels(Canvas& c); void drawChannels(Canvas& c);
void drawTracker(Canvas& c); void drawTracker(Canvas& c);
std::vector<ScanEntry> sortedScan() const;
WifiService& wifi_; WifiService& wifi_;
StorageService& storage_;
ClockService& clock_;
View view_ = View::Menu; View view_ = View::Menu;
ListModel menu_{theme::kContent.h / theme::kLineHeight}; 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 seenSeq_ = 0;
uint32_t nextScanMs_ = 0; uint32_t nextScanMs_ = 0;
// Scan logging
bool logging_ = false;
ScanLogThrottle logThrottle_{30000};
std::string headerDate_;
uint32_t loggedRows_ = 0;
// Tracker // Tracker
std::string targetBssid_, targetName_; std::string targetBssid_, targetName_;
int targetChannel_ = 0; int targetChannel_ = 0;
+16 -2
View File
@@ -1,5 +1,6 @@
// roro9stack firmware entry point: wires Services, Apps and the UI loop together. // roro9stack firmware entry point: wires Services, Apps and the UI loop together.
#include <M5Cardputer.h> #include <M5Cardputer.h>
#include <SD.h>
#include "app_manager.h" #include "app_manager.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
@@ -116,7 +117,7 @@ void setup() {
apps = new AppManager(launcher); apps = new AppManager(launcher);
launcher.setManager(*apps); launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); apps->registerApp({"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, apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
@@ -163,7 +164,8 @@ static void serialCommands() {
String k = line.substring(4); String k = line.substring(4);
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left 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; : 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 if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t'); int tab = line.indexOf('\t');
@@ -178,6 +180,18 @@ static void serialCommands() {
storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str()); storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str());
Serial.printf("log: queued to %s\n", path.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") { if (line == "sd list") {
storageService->requestListing(); storageService->requestListing();
listingWanted = true; listingWanted = true;
+1 -9
View File
@@ -4,6 +4,7 @@
#include "event_bus.h" #include "event_bus.h"
#include "saved_networks.h" #include "saved_networks.h"
#include "scan_entry.h"
#include "service.h" #include "service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "settings.h" #include "settings.h"
@@ -11,15 +12,6 @@
namespace roro { 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, // 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. // syncs the clock over NTP once Connected, and runs scans for the Settings network list.
class WifiService : public Service { class WifiService : public Service {
@@ -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();
}