Files
roro9stack/src/apps/wifi_tools_app.cpp
T
twislaandClaude Opus 5.5 34e6714785
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Keys: one table file for the device's help panel and the website's key tables (#72)
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00

283 lines
10 KiB
C++

#include "app_keys.h"
#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;
constexpr int kWeakRssi = -75;
} // namespace
void WifiToolsApp::onEnter() {
view_ = View::Menu;
menu_.setCount(3);
}
void WifiToolsApp::onExit() {
logging_ = false; // logging only runs while this foreground App is scanning
wifi_.endListScans();
}
void WifiToolsApp::open(View v) {
view_ = v;
nextScanMs_ = 0; // scan right away
requestRedraw();
}
void WifiToolsApp::refreshOrder() {
order_ = orderNetworks(wifi_.listScan(), sort_, filter_);
networks_.setCount(static_cast<int>(order_.size()));
}
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: open(menu_.selected() == 1 ? View::Channels : View::Networks); return true;
default: return false;
}
case View::Networks:
switch (e.key) {
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: {
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);
return true;
}
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;
}
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::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;
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 {
refreshOrder();
if (logging_) logScan(nowMs);
}
requestRedraw();
}
if (wifi_.listScanDone() && static_cast<int32_t>(nowMs - nextScanMs_) >= 0) {
bool tracking = view_ == View::Tracker;
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::help(std::vector<KeyHelp>& out) const {
switch (view_) {
case View::Menu: keys::add(out, keys::kWifiTools); break;
case View::Networks: keys::add(out, keys::kWifiNetworks); break;
case View::Channels: break; // nothing but the keys that work everywhere
case View::Tracker: keys::add(out, keys::kWifiTracker); break;
}
}
const char* WifiToolsApp::helpTitle() const {
switch (view_) {
case View::Networks: return "Networks nearby";
case View::Channels: return "Channel occupancy";
case View::Tracker: return "Signal tracker";
default: return nullptr;
}
}
void WifiToolsApp::drawNetworks(Canvas& c) {
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...",
},
0, list);
return;
}
widgets::list(
c, networks_, list,
[&](int i) {
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;
});
}
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_) + " clicks " + (clicks_ ? "on" : "off");
c.drawString(sub.c_str(), 4, area.y + 17);
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);
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);
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