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
@@ -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();
}