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
+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