Public Access
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:
+84
-36
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-2
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user