Public Access
M1 step 6: Wi-Fi Tools from ordinary scans
- lib/wifi (host-tested): channel occupancy (neighbour spill, signal weighting, quietest of 1/6/11) and a signal tracker (history, lost detection, click interval) - WifiService: scan results carry BSSID, channel and security; a scan can target one channel for quick tracker refreshes; endListScans() turns the radio back off when Wi-Fi is disabled - Wi-Fi Tools App: networks nearby, channel occupancy bars, signal tracker with clicks (m to mute) - M1 plan: Monitoring-mode views and captures deferred Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -73,7 +73,9 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) {
|
||||
}
|
||||
|
||||
case View::Scan: {
|
||||
const auto& found = wifi_.listScan();
|
||||
std::vector<ScanEntry> found;
|
||||
for (auto& n : wifi_.listScan())
|
||||
if (!n.ssid.empty()) found.push_back(n);
|
||||
if (wifi_.listScanDone()) scan_.setCount(static_cast<int>(found.size()));
|
||||
switch (e.key) {
|
||||
case Key::Up: scan_.up(); break;
|
||||
@@ -165,7 +167,9 @@ void WifiSettingsPage::draw(Canvas& c) {
|
||||
widgets::textLines(c, {"Scanning..."}, 0, area);
|
||||
break;
|
||||
}
|
||||
const auto& found = wifi_.listScan();
|
||||
std::vector<ScanEntry> found;
|
||||
for (auto& n : wifi_.listScan())
|
||||
if (!n.ssid.empty()) found.push_back(n);
|
||||
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
|
||||
if (found.empty()) {
|
||||
widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#include "wifi_tools_app.h"
|
||||
|
||||
#include <M5Cardputer.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "channel_occupancy.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"};
|
||||
constexpr uint32_t kListRescanMs = 4000;
|
||||
constexpr uint32_t kTrackRescanMs = 400;
|
||||
} // namespace
|
||||
|
||||
void WifiToolsApp::onEnter() {
|
||||
view_ = View::Menu;
|
||||
menu_.setCount(3);
|
||||
}
|
||||
|
||||
void WifiToolsApp::onExit() { wifi_.endListScans(); }
|
||||
|
||||
void WifiToolsApp::open(View v) {
|
||||
view_ = v;
|
||||
nextScanMs_ = 0; // scan right away
|
||||
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;
|
||||
}
|
||||
|
||||
bool WifiToolsApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
switch (view_) {
|
||||
case View::Menu:
|
||||
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;
|
||||
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::Select: {
|
||||
auto list = sortedScan();
|
||||
if (networks_.selected() < 0 || networks_.selected() >= static_cast<int>(list.size())) break;
|
||||
const ScanEntry& ap = list[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;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
case View::Channels:
|
||||
if (e.key == Key::Back) view_ = View::Menu;
|
||||
return true;
|
||||
case View::Tracker:
|
||||
if (e.key == Key::Back) view_ = View::Networks;
|
||||
if (e.key == Key::Char && (e.ch == 'm' || e.ch == 'M')) clicks_ = !clicks_;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void WifiToolsApp::update(uint32_t nowMs) {
|
||||
if (view_ == View::Menu) return;
|
||||
|
||||
if (wifi_.listScanSeq() != seenSeq_) {
|
||||
seenSeq_ = wifi_.listScanSeq();
|
||||
if (view_ == View::Tracker) {
|
||||
bool found = false;
|
||||
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()));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (view_ == View::Tracker && clicks_ && !tracker_.lost(nowMs) && static_cast<int32_t>(nowMs - nextClickMs_) >= 0) {
|
||||
M5Cardputer.Speaker.tone(2600, 8);
|
||||
nextClickMs_ = nowMs + SignalTracker::clickIntervalMs(tracker_.latest());
|
||||
}
|
||||
}
|
||||
|
||||
void WifiToolsApp::draw(Canvas& c) {
|
||||
switch (view_) {
|
||||
case View::Menu:
|
||||
widgets::list(c, menu_, theme::kContent, [](int i) { return std::string(kMenu[i]); });
|
||||
break;
|
||||
case View::Networks: drawNetworks(c); break;
|
||||
case View::Channels: drawChannels(c); break;
|
||||
case View::Tracker: drawTracker(c); break;
|
||||
}
|
||||
}
|
||||
|
||||
void WifiToolsApp::drawNetworks(Canvas& c) {
|
||||
auto list = sortedScan();
|
||||
if (list.empty()) {
|
||||
widgets::textLines(c, {wifi_.listScanDone() ? "No networks found yet." : "Scanning..."}, 0, theme::kContent);
|
||||
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; },
|
||||
[&](int i) {
|
||||
return "ch" + std::to_string(list[i].channel) + " " + std::to_string(list[i].rssi) + " " + list[i].security;
|
||||
});
|
||||
}
|
||||
|
||||
void WifiToolsApp::drawChannels(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
std::vector<ChannelSighting> sightings;
|
||||
for (auto& ap : wifi_.listScan()) sightings.push_back({ap.channel, ap.rssi});
|
||||
auto load = channelLoad(sightings);
|
||||
int best = quietestChannel(load);
|
||||
float maxLoad = 1.0f;
|
||||
for (int ch = 1; ch <= 13; ch++) maxLoad = std::max(maxLoad, load[ch]);
|
||||
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kText);
|
||||
std::string summary = std::to_string(sightings.size()) + " networks. Quietest of 1/6/11: " + std::to_string(best);
|
||||
c.drawString(summary.c_str(), 4, area.y + 2);
|
||||
|
||||
const int baseY = area.y + area.h - 14, barMaxH = area.h - 34, slot = 17, x0 = 10;
|
||||
c.setFont(&fonts::small);
|
||||
for (int ch = 1; ch <= 13; ch++) {
|
||||
int x = x0 + (ch - 1) * slot;
|
||||
int h = static_cast<int>(load[ch] / maxLoad * barMaxH);
|
||||
uint16_t color = ch == best ? theme::kMessage : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted;
|
||||
if (h > 0) c.fillRect(x, baseY - h, slot - 4, h, color);
|
||||
c.setTextColor(ch == best ? theme::kMessage : theme::kText);
|
||||
c.setTextDatum(top_center);
|
||||
c.drawString(std::to_string(ch).c_str(), x + (slot - 4) / 2, baseY + 3);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
c.drawFastHLine(x0 - 2, baseY, 13 * slot, theme::kMuted);
|
||||
}
|
||||
|
||||
void WifiToolsApp::drawTracker(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
uint32_t now = millis();
|
||||
bool lost = tracker_.lost(now);
|
||||
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(targetName_.c_str(), 4, area.y + 2);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
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);
|
||||
std::string reading = lost ? "lost" : std::to_string(tracker_.latest()) + " dBm";
|
||||
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());
|
||||
for (int i = 0; i < n; i++) {
|
||||
int v = h[i];
|
||||
if (v == SignalTracker::kNoSignal) continue;
|
||||
int bh = std::min(gh, std::max(1, (v + 95) * gh / 60));
|
||||
int x = area.w - 4 - (n - i) * 2;
|
||||
c.fillRect(x, gy + gh - bh, 2, bh, theme::kAccent);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "list_model.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.
|
||||
class WifiToolsApp : public App {
|
||||
public:
|
||||
explicit WifiToolsApp(WifiService& wifi) : wifi_(wifi) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { Menu, Networks, Channels, Tracker };
|
||||
|
||||
void open(View v);
|
||||
void drawNetworks(Canvas& c);
|
||||
void drawChannels(Canvas& c);
|
||||
void drawTracker(Canvas& c);
|
||||
std::vector<ScanEntry> sortedScan() const;
|
||||
|
||||
WifiService& wifi_;
|
||||
View view_ = View::Menu;
|
||||
ListModel menu_{theme::kContent.h / theme::kLineHeight};
|
||||
ListModel networks_{theme::kContent.h / theme::kLineHeight};
|
||||
uint32_t seenSeq_ = 0;
|
||||
uint32_t nextScanMs_ = 0;
|
||||
|
||||
// Tracker
|
||||
std::string targetBssid_, targetName_;
|
||||
int targetChannel_ = 0;
|
||||
SignalTracker tracker_{110};
|
||||
bool clicks_ = true;
|
||||
uint32_t nextClickMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/wifi_tools_app.h"
|
||||
#include "apps/setup_app.h"
|
||||
#include "event_bus.h"
|
||||
#include "key_mapper.h"
|
||||
@@ -115,6 +116,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({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
|
||||
@@ -11,7 +11,7 @@ int WifiService::rssi() const {
|
||||
return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0;
|
||||
}
|
||||
|
||||
void WifiService::startScan() {
|
||||
void WifiService::startScan(int channel) {
|
||||
if (!radioInitialised_) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(false); // the controller decides when to reconnect
|
||||
@@ -19,15 +19,45 @@ void WifiService::startScan() {
|
||||
radioInitialised_ = true;
|
||||
}
|
||||
if (scanRunning_) return;
|
||||
if (WiFi.scanNetworks(true /* async */, false /* no hidden */) == WIFI_SCAN_FAILED) return;
|
||||
// Hidden networks are listed too (with an empty name); the controller skips them.
|
||||
if (WiFi.scanNetworks(true /* async */, true /* hidden */, false, channel ? 120 : 300, channel) == WIFI_SCAN_FAILED)
|
||||
return;
|
||||
scanRunning_ = true;
|
||||
}
|
||||
|
||||
void WifiService::startListScan() {
|
||||
void WifiService::startListScan(int channel) {
|
||||
if (scanRunning_) {
|
||||
listScanPending_ = true; // results of the scan already running will do
|
||||
return;
|
||||
}
|
||||
listScanPending_ = true;
|
||||
startScan();
|
||||
startScan(channel);
|
||||
}
|
||||
|
||||
void WifiService::endListScans() {
|
||||
listScanPending_ = false;
|
||||
if (controller_.state() == WifiController::State::Off && radioInitialised_ && !scanRunning_) {
|
||||
WiFi.mode(WIFI_OFF);
|
||||
radioInitialised_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
const char* security(wifi_auth_mode_t auth) {
|
||||
switch (auth) {
|
||||
case WIFI_AUTH_OPEN: return "open";
|
||||
case WIFI_AUTH_WEP: return "WEP";
|
||||
case WIFI_AUTH_WPA_PSK: return "WPA";
|
||||
case WIFI_AUTH_WPA2_PSK:
|
||||
case WIFI_AUTH_WPA_WPA2_PSK: return "WPA2";
|
||||
case WIFI_AUTH_WPA2_ENTERPRISE: return "WPA2-E";
|
||||
case WIFI_AUTH_WPA3_PSK:
|
||||
case WIFI_AUTH_WPA2_WPA3_PSK: return "WPA3";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void WifiService::finishScan(uint32_t nowMs) {
|
||||
int n = WiFi.scanComplete();
|
||||
if (n == WIFI_SCAN_RUNNING) return;
|
||||
@@ -37,14 +67,16 @@ void WifiService::finishScan(uint32_t nowMs) {
|
||||
std::vector<ScanEntry> entries;
|
||||
for (int i = 0; i < n; i++) {
|
||||
std::string ssid = WiFi.SSID(i).c_str();
|
||||
if (ssid.empty()) continue;
|
||||
results.push_back({ssid, WiFi.RSSI(i)});
|
||||
entries.push_back({ssid, WiFi.RSSI(i), WiFi.encryptionType(i) == WIFI_AUTH_OPEN});
|
||||
wifi_auth_mode_t auth = WiFi.encryptionType(i);
|
||||
entries.push_back({ssid, WiFi.BSSIDstr(i).c_str(), WiFi.channel(i), WiFi.RSSI(i), auth == WIFI_AUTH_OPEN,
|
||||
security(auth)});
|
||||
if (!ssid.empty()) results.push_back({ssid, WiFi.RSSI(i)});
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
if (listScanPending_) {
|
||||
listScan_ = entries;
|
||||
listScanPending_ = false;
|
||||
listScanSeq_++;
|
||||
}
|
||||
apply(controller_.scanDone(results, nowMs));
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
namespace roro {
|
||||
|
||||
struct ScanEntry {
|
||||
std::string ssid;
|
||||
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,
|
||||
@@ -31,10 +34,14 @@ class WifiService : public Service {
|
||||
const std::string& ssid() const { return controller_.ssid(); }
|
||||
int rssi() const;
|
||||
|
||||
// Scan for the "add a network" list; results arrive in listScan() when listScanDone().
|
||||
void startListScan();
|
||||
// Scans for a list of nearby networks (all channels, or just one for a quick refresh); results
|
||||
// arrive in listScan() when listScanDone(), and listScanSeq() counts completed scans.
|
||||
void startListScan(int channel = 0);
|
||||
bool listScanDone() const { return !listScanPending_; }
|
||||
const std::vector<ScanEntry>& listScan() const { return listScan_; }
|
||||
uint32_t listScanSeq() const { return listScanSeq_; }
|
||||
// Done with list scans: switch the radio back off if Wi-Fi itself is off.
|
||||
void endListScans();
|
||||
|
||||
// A Saved Network was added: try it now rather than after the retry delay.
|
||||
void savedNetworksChanged() { controller_.retryNow(millis()); }
|
||||
@@ -44,7 +51,7 @@ class WifiService : public Service {
|
||||
|
||||
private:
|
||||
void apply(const WifiController::Step& step);
|
||||
void startScan();
|
||||
void startScan(int channel = 0);
|
||||
void finishScan(uint32_t nowMs);
|
||||
void startNtp();
|
||||
|
||||
@@ -55,6 +62,7 @@ class WifiService : public Service {
|
||||
bool scanRunning_ = false;
|
||||
bool listScanPending_ = false;
|
||||
std::vector<ScanEntry> listScan_;
|
||||
uint32_t listScanSeq_ = 0;
|
||||
bool ntpWaiting_ = false;
|
||||
bool radioInitialised_ = false;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user