diff --git a/CONTEXT.md b/CONTEXT.md index c9011ff..298cf9c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/README.md b/README.md index e1aad6f..ddd1e58 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 ` | Adds a Saved Network (so credentials stay out of the repo) | | `log ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | | `sd list` | Lists the files of each Storage Clean-up category | +| `cat ` | 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 ` | Types into a Buffer, commands included (`irc say 0 /join #test`) | | `irc dump` | Prints IRC status, memory, and the last lines of each Buffer | diff --git a/docs/milestones/M1.md b/docs/milestones/M1.md index 3d468e8..b4e50de 100644 --- a/docs/milestones/M1.md +++ b/docs/milestones/M1.md @@ -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. diff --git a/lib/storage_model/src/cleanup_plan.h b/lib/storage_model/src/cleanup_plan.h index 68c2605..c548e7e 100644 --- a/lib/storage_model/src/cleanup_plan.h +++ b/lib/storage_model/src/cleanup_plan.h @@ -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"}, }; diff --git a/lib/wifi/src/network_list_view.cpp b/lib/wifi/src/network_list_view.cpp new file mode 100644 index 0000000..aa3b0ff --- /dev/null +++ b/lib/wifi/src/network_list_view.cpp @@ -0,0 +1,59 @@ +#include "network_list_view.h" + +#include +#include + +namespace roro { + +namespace { +std::string lower(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} +} // namespace + +std::vector orderNetworks(const std::vector& entries, WifiSort sort, const NetworkFilter& filter) { + std::vector idx; + for (int i = 0; i < static_cast(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 diff --git a/lib/wifi/src/network_list_view.h b/lib/wifi/src/network_list_view.h new file mode 100644 index 0000000..83973c2 --- /dev/null +++ b/lib/wifi/src/network_list_view.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#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 orderNetworks(const std::vector& entries, WifiSort sort, const NetworkFilter& filter); + +const char* sortName(WifiSort sort); +WifiSort nextSort(WifiSort sort); + +} // namespace roro diff --git a/lib/wifi/src/scan_entry.h b/lib/wifi/src/scan_entry.h new file mode 100644 index 0000000..b8ba7b4 --- /dev/null +++ b/lib/wifi/src/scan_entry.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +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 diff --git a/lib/wifi/src/scan_log.cpp b/lib/wifi/src/scan_log.cpp new file mode 100644 index 0000000..0cb5cbf --- /dev/null +++ b/lib/wifi/src/scan_log.cpp @@ -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 diff --git a/lib/wifi/src/scan_log.h b/lib/wifi/src/scan_log.h new file mode 100644 index 0000000..fedd6a8 --- /dev/null +++ b/lib/wifi/src/scan_log.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#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 diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp index d03c000..d26daa9 100644 --- a/src/apps/wifi_tools_app.cpp +++ b/src/apps/wifi_tools_app.cpp @@ -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 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(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(list.size())) break; - const ScanEntry& ap = list[networks_.selected()]; + if (networks_.selected() < 0 || networks_.selected() >= static_cast(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(wifi_.listScan().size())); + refreshOrder(); + if (logging_) logScan(nowMs); } requestRedraw(); } if (wifi_.listScanDone() && static_cast(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(list.size())) networks_.setCount(static_cast(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(h.size()); diff --git a/src/apps/wifi_tools_app.h b/src/apps/wifi_tools_app.h index 75586f4..2fee3bf 100644 --- a/src/apps/wifi_tools_app.h +++ b/src/apps/wifi_tools_app.h @@ -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 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 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; diff --git a/src/main.cpp b/src/main.cpp index 567ebba..61ab354 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ // roro9stack firmware entry point: wires Services, Apps and the UI loop together. #include +#include #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 \t: 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; diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index 1bb7ec6..2aba9d0 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -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 { diff --git a/test/test_network_list_view/test_network_list_view.cpp b/test/test_network_list_view/test_network_list_view.cpp new file mode 100644 index 0000000..785968f --- /dev/null +++ b/test/test_network_list_view/test_network_list_view.cpp @@ -0,0 +1,87 @@ +#include + +#include "network_list_view.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static std::vector 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 names(const std::vector& e, const std::vector& idx) { + std::vector 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(WifiSort::Channel), static_cast(nextSort(WifiSort::Signal))); + TEST_ASSERT_EQUAL(static_cast(WifiSort::Name), static_cast(nextSort(WifiSort::Channel))); + TEST_ASSERT_EQUAL(static_cast(WifiSort::Signal), static_cast(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(); +} diff --git a/test/test_scan_log/test_scan_log.cpp b/test/test_scan_log/test_scan_log.cpp new file mode 100644 index 0000000..13ea890 --- /dev/null +++ b/test/test_scan_log/test_scan_log.cpp @@ -0,0 +1,68 @@ +#include + +#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(); +}