diff --git a/docs/milestones/M1.md b/docs/milestones/M1.md index 56dcaea..cde47cc 100644 --- a/docs/milestones/M1.md +++ b/docs/milestones/M1.md @@ -8,7 +8,7 @@ |---|---| | Q40 | Up to 8 **Saved Networks**, joined strongest-first. Added from a scan or as a hidden network. Open networks are allowed; enterprise (802.1X) is not. | | Q41 / Q48 | The Wi-Fi Service stays **Connected** whenever enabled and a Saved Network is in range. Settings has a Wi-Fi On/Off switch. **Monitoring** happens only while Wi-Fi Tools is open; the device reconnects on exit. | -| Q42 | Captures record **full frames**, from a fixed Wi-Fi channel or hopping, to `/captures/wifi/YYYYMMDD-HHMMSS.pcap`. Band-wide views hop across channels 1–13. | +| Q42 | *Revised 2026-10-02:* Wi-Fi Tools is built from ordinary scans only (access points, channel occupancy, signal tracker). Monitoring-mode views and captures are deferred, to be revisited later. | | Q43 | The signal tracker clicks faster as the signal gets stronger. On by default, mutable. | | Q44 | IRC shows one Buffer at a time; Tab cycles Buffers. Commands: `/join /part /msg /me /nick /topic /names /quit /raw`. Logs go to `/irc///YYYY-MM-DD.log`. | | Q45 | TLS verifies server certificates against the bundled certificate authorities. Per server, a self-signed certificate can be pinned on first use. | @@ -26,7 +26,7 @@ - Settings → Wi-Fi: On/Off, scan and add a network (with password), add a hidden network, forget a network. - The IRC App configures one server (host, port, TLS, nick, SASL or NickServ, auto-join IRC channels). It connects and keeps running after you leave the App. Mentions notify. - IRC reconnects after Wi-Fi loss or a server drop, and rejoins its IRC channels. Logs are written to the SD card (and stop past 90% usage). -- Wi-Fi Tools: access-point list, channel occupancy, signal tracker, packet counters, probe requests (logged), and pcap capture that opens in Wireshark. +- Wi-Fi Tools: access-point list, channel occupancy with the quietest of channels 1/6/11, and a signal tracker that clicks faster as the signal gets stronger. None of it interrupts the connection or IRC. - Settings → Storage → Clean-up deletes IRC Logs, probe Logs and Wi-Fi Captures older than a chosen age, with a preview of the space freed. - Free heap stays above about 40 KB with Wi-Fi, TLS-connected IRC and the UI running. @@ -50,4 +50,4 @@ Then the TLS transport with the certificate bundle. 5. **IRC App:** Buffer view, input line, command parser (host-tested), server settings page. -6. **Wi-Fi Tools App:** the six views; a pcap writer (host-tested); channel hopper; clicker for the signal tracker. +6. **Wi-Fi Tools App:** access-point list, channel occupancy and signal tracker from scans (occupancy and tracker logic host-tested). Monitoring mode is deferred. diff --git a/lib/wifi/src/channel_occupancy.cpp b/lib/wifi/src/channel_occupancy.cpp new file mode 100644 index 0000000..89a6105 --- /dev/null +++ b/lib/wifi/src/channel_occupancy.cpp @@ -0,0 +1,35 @@ +#include "channel_occupancy.h" + +namespace roro { + +namespace { +// -90 dBm (barely there) -> 0.1, -40 dBm and stronger -> 1. +float weight(int rssi) { + float w = (rssi + 90) / 50.0f; + if (w < 0.1f) w = 0.1f; + if (w > 1.0f) w = 1.0f; + return w; +} +const float kSpill[] = {1.0f, 0.5f, 0.2f}; // same channel, one away, two away +} // namespace + +std::array channelLoad(const std::vector& sightings) { + std::array load{}; + for (auto& s : sightings) { + if (s.channel < 1 || s.channel > 13) continue; + for (int d = -2; d <= 2; d++) { + int ch = s.channel + d; + if (ch >= 1 && ch <= 13) load[ch] += weight(s.rssi) * kSpill[d < 0 ? -d : d]; + } + } + return load; +} + +int quietestChannel(const std::array& load) { + int best = 1; + for (int ch : {6, 11}) + if (load[ch] < load[best]) best = ch; + return best; +} + +} // namespace roro diff --git a/lib/wifi/src/channel_occupancy.h b/lib/wifi/src/channel_occupancy.h new file mode 100644 index 0000000..58b9e79 --- /dev/null +++ b/lib/wifi/src/channel_occupancy.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace roro { + +struct ChannelSighting { + int channel; + int rssi; +}; + +// How crowded each 2.4 GHz Wi-Fi channel (1..13) is, from the access points a scan saw. A 20 MHz +// network spills onto the channels up to two away, less the further it is; a strong signal +// counts more than a faint one. Index 0 is unused. +std::array channelLoad(const std::vector& sightings); + +// The least crowded of the non-overlapping channels 1, 6 and 11 (the lowest on a tie). +int quietestChannel(const std::array& load); + +} // namespace roro diff --git a/lib/wifi/src/signal_tracker.h b/lib/wifi/src/signal_tracker.h new file mode 100644 index 0000000..7322800 --- /dev/null +++ b/lib/wifi/src/signal_tracker.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// Follows one access point's signal over repeated scans, for finding where it is. +class SignalTracker { + public: + static constexpr int kNoSignal = -127; + static constexpr uint32_t kLostAfterMs = 5000; + static constexpr uint32_t kFastestClickMs = 60; + static constexpr uint32_t kSlowestClickMs = 1200; + + explicit SignalTracker(size_t historySize) : size_(historySize) {} + + void seen(int rssi, uint32_t nowMs) { + push(rssi); + latest_ = rssi; + lastSeenMs_ = nowMs; + everSeen_ = true; + } + void missed() { push(kNoSignal); } // a scan that didn't see it + + bool lost(uint32_t nowMs) const { return !everSeen_ || nowMs - lastSeenMs_ >= kLostAfterMs; } + int latest() const { return latest_; } + const std::deque& history() const { return history_; } + + // Time between clicks: fast when the signal is strong (close), slow when faint. + static uint32_t clickIntervalMs(int rssi) { + if (rssi >= -35) return kFastestClickMs; + if (rssi <= -95) return kSlowestClickMs; + float t = (rssi + 95) / 60.0f; // 0 at -95 dBm, 1 at -35 dBm + return static_cast(kSlowestClickMs - t * (kSlowestClickMs - kFastestClickMs)); + } + + private: + void push(int v) { + history_.push_back(v); + if (history_.size() > size_) history_.pop_front(); + } + + size_t size_; + std::deque history_; + int latest_ = kNoSignal; + uint32_t lastSeenMs_ = 0; + bool everSeen_ = false; +}; + +} // namespace roro diff --git a/src/apps/wifi_settings_page.cpp b/src/apps/wifi_settings_page.cpp index ec626d8..7e57ced 100644 --- a/src/apps/wifi_settings_page.cpp +++ b/src/apps/wifi_settings_page.cpp @@ -73,7 +73,9 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) { } case View::Scan: { - const auto& found = wifi_.listScan(); + std::vector found; + for (auto& n : wifi_.listScan()) + if (!n.ssid.empty()) found.push_back(n); if (wifi_.listScanDone()) scan_.setCount(static_cast(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 found; + for (auto& n : wifi_.listScan()) + if (!n.ssid.empty()) found.push_back(n); if (scan_.count() != static_cast(found.size())) scan_.setCount(static_cast(found.size())); if (found.empty()) { widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area); diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp new file mode 100644 index 0000000..d03c000 --- /dev/null +++ b/src/apps/wifi_tools_app.cpp @@ -0,0 +1,215 @@ +#include "wifi_tools_app.h" + +#include + +#include + +#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 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(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(wifi_.listScan().size())); + } + 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); + } + + if (view_ == View::Tracker && clicks_ && !tracker_.lost(nowMs) && static_cast(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(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; }, + [&](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 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(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(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 diff --git a/src/apps/wifi_tools_app.h b/src/apps/wifi_tools_app.h new file mode 100644 index 0000000..75586f4 --- /dev/null +++ b/src/apps/wifi_tools_app.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#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 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 diff --git a/src/main.cpp b/src/main.cpp index d49b617..8dc1b21 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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)}); diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index b1b088b..161d324 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -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 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)); } diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index a5d92bb..1bb7ec6 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -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& 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 listScan_; + uint32_t listScanSeq_ = 0; bool ntpWaiting_ = false; bool radioInitialised_ = false; }; diff --git a/test/test_wifi_survey/test_wifi_survey.cpp b/test/test_wifi_survey/test_wifi_survey.cpp new file mode 100644 index 0000000..9e1da82 --- /dev/null +++ b/test/test_wifi_survey/test_wifi_survey.cpp @@ -0,0 +1,78 @@ +#include + +#include "channel_occupancy.h" +#include "signal_tracker.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_empty_band_is_quiet_everywhere() { + auto load = channelLoad({}); + for (int ch = 1; ch <= 13; ch++) TEST_ASSERT_EQUAL_FLOAT(0, load[ch]); +} + +void test_an_access_point_also_loads_its_neighbours_less() { + auto load = channelLoad({{6, -50}}); + TEST_ASSERT_TRUE(load[6] > load[5]); + TEST_ASSERT_TRUE(load[5] > load[4]); + TEST_ASSERT_TRUE(load[4] > 0); + TEST_ASSERT_EQUAL_FLOAT(0, load[3]); + TEST_ASSERT_EQUAL_FLOAT(load[5], load[7]); +} + +void test_stronger_signals_weigh_more() { + auto strong = channelLoad({{6, -40}}); + auto weak = channelLoad({{6, -85}}); + TEST_ASSERT_TRUE(strong[6] > weak[6]); + TEST_ASSERT_TRUE(weak[6] > 0); +} + +void test_recommends_the_quietest_of_1_6_11() { + TEST_ASSERT_EQUAL(11, quietestChannel(channelLoad({{1, -50}, {6, -50}, {6, -60}}))); + TEST_ASSERT_EQUAL(1, quietestChannel(channelLoad({{11, -50}, {6, -50}}))); + TEST_ASSERT_EQUAL(1, quietestChannel(channelLoad({}))); // ties: the lowest +} + +void test_out_of_band_channels_are_ignored() { + auto load = channelLoad({{14, -40}, {0, -40}, {36, -40}}); + for (int ch = 1; ch <= 13; ch++) TEST_ASSERT_EQUAL_FLOAT(0, load[ch]); +} + +void test_tracker_keeps_recent_readings() { + SignalTracker t(4); + for (int r : {-80, -70, -60, -50, -40}) t.seen(r, 0); + TEST_ASSERT_EQUAL(4, t.history().size()); + TEST_ASSERT_EQUAL(-70, t.history().front()); + TEST_ASSERT_EQUAL(-40, t.latest()); +} + +void test_tracker_reports_lost_after_a_while_unseen() { + SignalTracker t(8); + t.seen(-60, 1000); + TEST_ASSERT_FALSE(t.lost(3000)); + TEST_ASSERT_TRUE(t.lost(1000 + SignalTracker::kLostAfterMs)); + t.missed(); + TEST_ASSERT_EQUAL(SignalTracker::kNoSignal, t.history().back()); +} + +void test_clicks_speed_up_as_the_signal_gets_stronger() { + TEST_ASSERT_TRUE(SignalTracker::clickIntervalMs(-40) < SignalTracker::clickIntervalMs(-60)); + TEST_ASSERT_TRUE(SignalTracker::clickIntervalMs(-60) < SignalTracker::clickIntervalMs(-85)); + TEST_ASSERT_EQUAL_UINT32(SignalTracker::kFastestClickMs, SignalTracker::clickIntervalMs(-20)); + TEST_ASSERT_EQUAL_UINT32(SignalTracker::kSlowestClickMs, SignalTracker::clickIntervalMs(-100)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_empty_band_is_quiet_everywhere); + RUN_TEST(test_an_access_point_also_loads_its_neighbours_less); + RUN_TEST(test_stronger_signals_weigh_more); + RUN_TEST(test_recommends_the_quietest_of_1_6_11); + RUN_TEST(test_out_of_band_channels_are_ignored); + RUN_TEST(test_tracker_keeps_recent_readings); + RUN_TEST(test_tracker_reports_lost_after_a_while_unseen); + RUN_TEST(test_clicks_speed_up_as_the_signal_gets_stronger); + return UNITY_END(); +}