From 04bac091771b53eb8b71627da515eb79f4f92d1d Mon Sep 17 00:00:00 2001 From: Elliot Matson Date: Sun, 4 Oct 2026 14:08:01 -0500 Subject: [PATCH 1/2] Add MQTT telemetry and remote settings Cubes report to an MQTT broker over wss:// and take settings from it. Each picks a random ID once and publishes under cube//: status (retained, with an offline last will), boot (version, boot log, MAC, SSID/BSSID, local and public IP), health, usage and perf every interval, a WiFi scan at boot and hourly, crash summaries and core dumps (once per dump, remembered by CRC once the broker acknowledges every part), and retained settings. Commands arrive on cube//set/, checked by lib/remote (hardware free, host tested) and applied the way the dashboard applies them; results go to ack. The broker and its shared login come from the gitignored secrets.h; builds without one never connect. The Startup card's neighbour on the System tab and /api/v1/telemetry show the connection. Also: Updates::checkNow() (the check_updates command), and CONFIG_MBEDTLS_DEBUG off -- ESP-IDF's TLS layer, which the MQTT client uses, turned it into a dozen log lines per TLS record, and on the USB-Serial-JTAG console those block when no host is reading. CLAUDE.md now says the generated sdkconfig is only regenerated when missing. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 18 +- README.md | 47 +++ lib/cube/cube.cpp | 160 +++++++- lib/cube/cube.h | 8 +- lib/cube/settings.cpp | 21 + lib/cube/settings.h | 19 + lib/cube/telemetry.cpp | 671 +++++++++++++++++++++++++++++++ lib/cube/telemetry.h | 107 +++++ lib/cube/updates.cpp | 13 +- lib/cube/updates.h | 3 + lib/remote/remote.cpp | 150 +++++++ lib/remote/remote.h | 76 ++++ sdkconfig.defaults | 5 +- test/test_remote/test_remote.cpp | 99 +++++ 14 files changed, 1379 insertions(+), 18 deletions(-) create mode 100644 lib/cube/telemetry.cpp create mode 100644 lib/cube/telemetry.h create mode 100644 lib/remote/remote.cpp create mode 100644 lib/remote/remote.h create mode 100644 test/test_remote/test_remote.cpp diff --git a/CLAUDE.md b/CLAUDE.md index a4f7b2f..d73db27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,11 @@ the dashboard first (a fresh cube has it off). To flash over USB, comment out ## Configuration `sdkconfig.defaults` is the source of truth. `sdkconfig.esp32-s3-devkitc-1` is -generated from it on every build and is gitignored. To change a setting, run -`pio run -t menuconfig`, then `pio run -t save-defconfig`, and diff before -committing — save-defconfig strips comments. +generated from it and gitignored -- but only when it is missing, so after +editing `sdkconfig.defaults` delete it to make the change take effect (a full +rebuild follows). To change a setting, run `pio run -t menuconfig`, then +`pio run -t save-defconfig`, and diff before committing — save-defconfig +strips comments. `lib/cube/config.h` holds pins, panel geometry and build identity. Runtime settings live in `lib/cube/settings.*`: one NVS key each in namespace `cube`, @@ -55,6 +57,8 @@ lib/cube/cube.* Cube: startup, display, WiFi/time, dashboard cards, lib/cube/renderer.* the render task, canvas and pattern switching lib/cube/updates.* upload card, ArduinoOTA, GitHub updater, update progress screen lib/cube/settings.* persistent settings (NVS) +lib/cube/boot_log.* the last few boots: version, reset reason, how far startup got +lib/cube/telemetry.* MQTT reports to the broker and settings from it (see README, Telemetry) lib/cube_utils/ Pattern base class; SinglePanel/BottomPanels views (one ChainView base) lib/cube_geometry/ hardware-free: face mappings, seam stepping, 3D surface mapping, projection lib/life/ hardware-free Game of Life step @@ -71,6 +75,7 @@ lib/langton/ hardware-free Langton's ant and turmites on any cel lib/arcade/ hardware-free game rules: Pong and Breakout lib/wordclock/ hardware-free word clock: which letters spell the time lib/weather/ hardware-free weather codes, units and URL encoding for the Weather pattern +lib/remote/ hardware-free names, types and checks for settings set over MQTT test/ host unit tests for the hardware-free libraries ([env:native]) lib/patterns// one folder per pattern; registered in lib/patterns/all_patterns.cpp lib/fonts/ GFX fonts, each defined once in fonts.cpp: include fonts.h, never a font's own header @@ -85,7 +90,7 @@ for a pixel's 3D position on the cube surface (`cube::fromCube` back), and `cube isometric projection continuous across the seams. `lib/cube_geometry`, `lib/life`, `lib/timezones`, `lib/firmware_image`, -`lib/noise`, `lib/color`, `lib/rubiks`, `lib/sand`, `lib/bounce`, `lib/particles`, `lib/maze`, `lib/langton`, `lib/arcade`, `lib/wordclock` and `lib/weather` include nothing from Arduino or ESP-IDF, so +`lib/noise`, `lib/color`, `lib/rubiks`, `lib/sand`, `lib/bounce`, `lib/particles`, `lib/maze`, `lib/langton`, `lib/arcade`, `lib/wordclock`, `lib/weather` and `lib/remote` include nothing from Arduino or ESP-IDF, so `[env:native]` can test them on the host. Keep it that way, and put new pure logic in libraries like these so it can be tested too. The board env takes its settings from `[esp32_base]` rather than `[env]`, which would leak the @@ -164,6 +169,11 @@ scripts/capture_patterns.py --host cube.local ` (frames come from A reset before that point rolls the update back. `BootLog` records every boot's version, reset reason and how far startup got (`/api/v1/boots`, and the Startup card): a failed startup shows the stage it stopped at. +- **Telemetry credentials are public.** `MQTT_URL`, `MQTT_USER` and + `MQTT_PASSWORD` come from the gitignored `secrets.h` (CI writes it from + repository secrets), but anything compiled in can be read out of a release. + The broker's ACL is what keeps the shared login to a cube's own topics; a + new setting reachable over MQTT goes in `lib/remote` and `Cube::applyRemote`. - **Updates only go forwards.** The GitHub updater installs a release only if `firmware_image::isNewer()` says its tag is newer than `FW_VERSION`. GitHub's "latest" is the newest *stable* release, older than any beta. diff --git a/README.md b/README.md index fca08a9..b76664b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Recorded from a running cube with `scripts/capture_patterns.py` and drawn as the - [Uploading](#uploading) - [Spotify](#spotify) - [Weather](#weather) + - [Telemetry](#telemetry) ## Development @@ -89,6 +90,52 @@ By default the cube works out where it is from its IP address, which can be off The same settings are at `/api/v1/weather`: GET for what the pattern knows, POST `location=` and/or `metric=true|false`. POST `preview=` (and `day=false` for night) shows a kind of weather for two minutes, to try the animations: 0 clear, 2 partly cloudy, 3 overcast, 45 fog, 53 drizzle, 63 rain, 73 snow, 95 thunderstorm. +## Telemetry + +Release builds report to an MQTT broker, and take settings from it, so cubes in the field can be followed and managed. **While the project is in testing this is always on, with no opt-out.** A build reports only if it was given a broker (`MQTT_URL`, `MQTT_USER`, `MQTT_PASSWORD` in `lib/cube/secrets.h`, written by CI from repository secrets); other builds never connect. + +Each cube picks a random ID once and publishes under `cube//`, as JSON: + +| Topic | When | Contents | +|---|---|---| +| `status` | retained | `online`, or `offline` (the broker publishes that if the cube drops) | +| `boot` | each boot | firmware version and build, chip, MAC address, WiFi network (SSID, BSSID, signal, channel), local and **public IP**, the boot log (reset reasons, rollbacks, how far startup got) | +| `health` | every interval | uptime, memory and its low-water marks, signal, current access point (BSSID), pattern | +| `usage` | every interval | seconds per pattern, brightness, whether Spotify, a weather location and a ticker message are set | +| `perf` | every interval | the renderer's frame rate and tick/push times | +| `wifi` | boot, then hourly | a scan of the networks in range: SSID, BSSID, signal, channel, security | +| `crash` | once per crash | a summary (task, PC, cause, backtrace), then the core dump in base64 parts on `crash/` | +| `settings` | retained | current settings | +| `ack` | per command | whether a command was applied, and why not | + +The interval defaults to 5 minutes. No Spotify or WiFi credentials are ever sent. + +Commands are messages to `cube//set/`: + +| Name | Value | +|---|---| +| `pattern` | a pattern id (see `/api/v1/patterns`) | +| `brightness` | 0-255 | +| `ticker`, `timezone`, `weather_location` | text | +| `weather_metric`, `github_updates`, `development`, `ota` | `true` / `false` | +| `telemetry_interval` | seconds, 60-86400 | +| `report_health`, `report_usage`, `report_perf` | `true` / `false` | +| `restart`, `check_updates`, `resend_crash` | anything (actions) | + +### Broker setup + +The firmware connects with `wss://` (MQTT over WebSockets, TLS on a standard HTTPS port), checking the certificate against ESP-IDF's bundle, so the broker needs a publicly trusted certificate. Every cube shares one login, which is readable from any published image, so the broker must confine it. With EMQX: + +``` +{allow, {username, "admin"}, all, ["#"]}. +{deny, {username, "cube"}, publish, ["cube/${clientid}/set/#"]}. +{allow, {username, "cube"}, publish, ["cube/${clientid}/#"]}. +{allow, {username, "cube"}, subscribe, ["cube/${clientid}/set/#"]}. +{deny, all}. +``` + +A cube can then only publish its own reports and read its own commands; commands come from a separate admin login that never goes in firmware. + ## Recording the pattern animations The animations above come from the cube itself: `GET /api/v1/frame` returns the frame it is showing, and the script switches through every pattern and records each one. diff --git a/lib/cube/cube.cpp b/lib/cube/cube.cpp index e47d9ca..5d1ba1e 100644 --- a/lib/cube/cube.cpp +++ b/lib/cube/cube.cpp @@ -26,6 +26,7 @@ Cube::Cube() : leds(4, USR_LED, NEO_GRB + NEO_KHZ800), spotifyClientSecret(dashboard, "Spotify Client Secret", "Saved; type to replace"), spotifyLogout(dashboard, "Log out of Spotify"), bootStatus(dashboard, "Startup", dash::Status::NONE), + telemetryStatus(dashboard, "Telemetry", dash::Status::NONE), weatherStatus(dashboard, "Weather", dash::Status::NONE), weatherLocation(dashboard, "Weather Location", "City or postcode; blank to use the cube's IP address"), weatherMetric(dashboard, "Weather in \u00b0C and km/h"), @@ -74,6 +75,7 @@ void Cube::init() renderer.begin(dma_display, &server, [this](Pattern *pattern) { settings.setPattern(pattern->getId()); + telemetry.patternShown(pattern->getId()); dashboard.sendUpdates(); }); bootLog.reached(BootLog::BOOT_PATTERNS); @@ -83,6 +85,9 @@ void Cube::init() bootLog.reached(BootLog::BOOT_UI); updates.begin(server, dma_display, settings, renderer, systemTab); bootLog.reached(BootLog::BOOT_UPDATES); + // Reports to the broker, and settings from it, if this build has one. + telemetry.begin(settings, bootLog, renderer, [this](const remote::Command &cmd) + { return applyRemote(cmd); }); leds.setPixelColor(3, 0, 255, 0); leds.show(); @@ -227,7 +232,7 @@ void Cube::refreshWeatherStatus() } /// From the dashboard or the API: save, pass on, and show the result. -void Cube::setWeatherLocation(std::string text) +void Cube::setWeatherLocation(std::string text, bool show) { while (!text.empty() && isspace((unsigned char)text.back())) text.pop_back(); @@ -240,12 +245,130 @@ void Cube::setWeatherLocation(std::string text) settings.setWeatherLocation(text); WeatherPattern::setLocation(text); weatherLocation.setValue(text.c_str()); - // Show it: the pattern looks the place up, and the status follows. - showPattern("weather"); + // Show it: the pattern looks the place up, and the status follows. Not + // when it was set remotely: whoever is looking at the cube did not ask. + if (show) + { + showPattern("weather"); + } refreshWeatherStatus(); dashboard.sendUpdates(); } +void Cube::setTimezone(const timezones::Zone &zone) +{ + settings.setTimezone(zone.name); + setenv("TZ", zone.posix, 1); + tzset(); + timezoneDropdown.setValue(zone.name); + dashboard.sendUpdates(); +} + +/** + * A setting from the MQTT broker (see Telemetry, lib/remote): applied the + * way the dashboard applies it, so the dashboard shows it too. Returns "" or + * why it could not be applied. Runs in the MQTT client's task. + */ +std::string Cube::applyRemote(const remote::Command &cmd) +{ + using remote::Setting; + switch (cmd.id) + { + case Setting::PATTERN: + for (Pattern *p : patternList) + { + if (p->getId() == cmd.text) + { + showPattern(cmd.text.c_str()); + return ""; + } + } + return "no such pattern"; + case Setting::BRIGHTNESS: + setBrightness(uint8_t(cmd.number)); + brightnessSlider.setValue(int(cmd.number)); + break; + case Setting::TICKER: + { + std::string text = cmd.text; + if (text.size() > TICKER_MAX_LENGTH) + { + text.resize(TICKER_MAX_LENGTH); + } + settings.setTickerText(text); + Ticker::setMessage(text); + tickerInput.setValue(text.c_str()); + break; + } + case Setting::TIMEZONE: + { + const timezones::Zone *zone = timezones::find(cmd.text.c_str()); + if (zone == nullptr) + { + return "unknown time zone"; + } + setTimezone(*zone); + break; + } + case Setting::WEATHER_LOCATION: + setWeatherLocation(cmd.text, false); + break; + case Setting::WEATHER_METRIC: + settings.setWeatherMetric(cmd.flag); + WeatherPattern::setMetric(cmd.flag); + weatherMetric.setValue(cmd.flag); + break; + case Setting::GITHUB_UPDATES: + updates.setGithub(cmd.flag); + GHUpdateToggle.setValue(cmd.flag); + break; + case Setting::DEVELOPMENT: + setDevelopment(cmd.flag); + developmentToggle.setValue(cmd.flag); + break; + case Setting::OTA: + updates.setOta(cmd.flag); + otaToggle.setValue(cmd.flag); + break; + case Setting::TELEMETRY_INTERVAL: + settings.setTelemetryInterval(uint32_t(cmd.number)); + break; + case Setting::REPORT_HEALTH: + settings.setReportHealth(cmd.flag); + break; + case Setting::REPORT_USAGE: + settings.setReportUsage(cmd.flag); + break; + case Setting::REPORT_PERF: + settings.setReportPerf(cmd.flag); + break; + case Setting::CHECK_UPDATES: + if (!updates.checkNow()) + { + return "GitHub updates are off"; + } + break; + default: + return "not handled here"; + } + dashboard.sendUpdates(); + return ""; +} + +void Cube::refreshTelemetryStatus() +{ + const Telemetry::Status t = telemetry.status(); + if (!t.configured) + { + telemetryStatus.setFeedback("Not in this build (no broker configured)", dash::Status::NONE); + return; + } + char text[96]; + snprintf(text, sizeof(text), "%s as %s: %lu sent, %lu failed, %lu commands", t.connected ? "Connected" : "Not connected", + t.deviceId, (unsigned long)t.published, (unsigned long)t.failed, (unsigned long)t.commands); + telemetryStatus.setFeedback(text, t.connected ? dash::Status::SUCCESS : dash::Status::WARNING); +} + /// The saved time zone, or the default if none is saved or it is unknown. const timezones::Zone &Cube::currentTimezone() { @@ -425,15 +548,10 @@ void Cube::initUI() timezoneDropdown.onChange([this](const dash::string &name) { const timezones::Zone *zone = timezones::find(name.c_str()); - if (zone == nullptr) + if (zone != nullptr) { - return; - } - settings.setTimezone(zone->name); - setenv("TZ", zone->posix, 1); - tzset(); - timezoneDropdown.setValue(zone->name); - dashboard.sendUpdates(); }); + setTimezone(*zone); + } }); this->otaToggle.setValue(settings.ota()); this->developmentToggle.setValue(settings.development()); this->GHUpdateToggle.setValue(settings.github()); @@ -483,6 +601,7 @@ void Cube::initUI() { refreshSpotifyStatus(); refreshWeatherStatus(); + refreshTelemetryStatus(); } }); refreshSpotifyStatus(); @@ -527,6 +646,7 @@ void Cube::initUI() bootStatus.setFeedback(bootLog.summary().c_str(), trouble ? dash::Status::WARNING : dash::Status::SUCCESS); bootStatus.setTab(systemTab); } + telemetryStatus.setTab(systemTab); this->rebootButton.setTab(systemTab); this->resetWifiButton.setTab(systemTab); @@ -615,6 +735,24 @@ void Cube::initAPI() (unsigned long)(millis() / 1000)); request->send(200, "application/json", body); }); + // Telemetry: whether this build reports, as which device, and how it is + // going. + sprintf(uri, "%s/v1/telemetry", API_ENDPOINT); + server.on(uri, HTTP_GET, [&](AsyncWebServerRequest *request) + { + const Telemetry::Status t = telemetry.status(); + JsonDocument doc; + doc["configured"] = t.configured; + doc["connected"] = t.connected; + doc["device_id"] = t.deviceId; + doc["published"] = t.published; + doc["failed"] = t.failed; + doc["commands"] = t.commands; + doc["interval_s"] = settings.telemetryInterval(); + String body; + serializeJson(doc, body); + request->send(200, "application/json", body); }); + // The last few boots, newest first: version, how each started (the // previous one's reset reason), whether it was the first boot of an // update, and how far startup got. See BootLog. diff --git a/lib/cube/cube.h b/lib/cube/cube.h index 043a60a..927622a 100644 --- a/lib/cube/cube.h +++ b/lib/cube/cube.h @@ -35,6 +35,7 @@ #include "config.h" #include "settings.h" #include "boot_log.h" +#include "telemetry.h" #include "renderer.h" #include "updates.h" #include "timezones.h" @@ -59,6 +60,7 @@ class Cube WiFiManager wifiManager; Settings settings; BootLog bootLog; + Telemetry telemetry; Renderer renderer; std::unordered_map patterns; std::vector patternButtonLabels; @@ -88,6 +90,7 @@ class Cube dash::PasswordCard spotifyClientSecret; dash::PushButtonCard spotifyLogout; dash::FeedbackCard<> bootStatus; + dash::FeedbackCard<> telemetryStatus; dash::FeedbackCard<> weatherStatus; dash::InputCard weatherLocation; dash::ToggleButtonCard weatherMetric; @@ -117,7 +120,10 @@ class Cube void showPattern(const char *id, bool onlyIfShowing = false); void refreshSpotifyStatus(); void refreshWeatherStatus(); - void setWeatherLocation(std::string text); + void setWeatherLocation(std::string text, bool show = true); + void setTimezone(const timezones::Zone &zone); + std::string applyRemote(const remote::Command &cmd); + void refreshTelemetryStatus(); }; #endif \ No newline at end of file diff --git a/lib/cube/settings.cpp b/lib/cube/settings.cpp index 3b7bb4d..be11468 100644 --- a/lib/cube/settings.cpp +++ b/lib/cube/settings.cpp @@ -13,11 +13,16 @@ static const char *K_TIMEZONE = "timezone"; static const char *K_TICKER = "ticker"; static const char *K_WEATHER_LOCATION = "wxLocation"; static const char *K_WEATHER_METRIC = "wxMetric"; +static const char *K_TELEMETRY_INTERVAL = "tmInterval"; +static const char *K_REPORT_HEALTH = "tmHealth"; +static const char *K_REPORT_USAGE = "tmUsage"; +static const char *K_REPORT_PERF = "tmPerf"; static const char *K_LEGACY = "cubePrefs"; static const uint64_t FLUSH_DELAY_US = 2 * 1000 * 1000; static uint8_t clampLatch(int value) { return value < 1 ? 1 : (value > 4 ? 4 : value); } +static uint32_t clampInterval(uint32_t s) { return s < 60 ? 60 : (s > 86400 ? 86400 : s); } bool Settings::begin() { @@ -48,6 +53,10 @@ bool Settings::begin() _tickerText = _prefs.getString(K_TICKER, "").c_str(); _weatherLocation = _prefs.getString(K_WEATHER_LOCATION, "").c_str(); _weatherMetric = _prefs.getBool(K_WEATHER_METRIC, _weatherMetric); + _telemetryInterval = clampInterval(_prefs.getUInt(K_TELEMETRY_INTERVAL, _telemetryInterval)); + _reportHealth = _prefs.getBool(K_REPORT_HEALTH, _reportHealth); + _reportUsage = _prefs.getBool(K_REPORT_USAGE, _reportUsage); + _reportPerf = _prefs.getBool(K_REPORT_PERF, _reportPerf); if (_prefs.isKey("HW")) { _hardware = _prefs.getString("HW").c_str(); @@ -127,6 +136,10 @@ SETTER(setTimezone(const std::string &name), _timezone, TIMEZONE, name) SETTER(setTickerText(const std::string &text), _tickerText, TICKER, text) SETTER(setWeatherLocation(const std::string &text), _weatherLocation, WEATHER_LOCATION, text) SETTER(setWeatherMetric(bool value), _weatherMetric, WEATHER_METRIC, value) +SETTER(setTelemetryInterval(uint32_t seconds), _telemetryInterval, TELEMETRY_INTERVAL, clampInterval(seconds)) +SETTER(setReportHealth(bool value), _reportHealth, REPORT_HEALTH, value) +SETTER(setReportUsage(bool value), _reportUsage, REPORT_USAGE, value) +SETTER(setReportPerf(bool value), _reportPerf, REPORT_PERF, value) #undef SETTER @@ -191,6 +204,14 @@ void Settings::flush() _prefs.putString(K_WEATHER_LOCATION, _weatherLocation.c_str()); if (dirty & WEATHER_METRIC) _prefs.putBool(K_WEATHER_METRIC, _weatherMetric); + if (dirty & TELEMETRY_INTERVAL) + _prefs.putUInt(K_TELEMETRY_INTERVAL, _telemetryInterval); + if (dirty & REPORT_HEALTH) + _prefs.putBool(K_REPORT_HEALTH, _reportHealth); + if (dirty & REPORT_USAGE) + _prefs.putBool(K_REPORT_USAGE, _reportUsage); + if (dirty & REPORT_PERF) + _prefs.putBool(K_REPORT_PERF, _reportPerf); if (dirty) { log("Saved settings"); diff --git a/lib/cube/settings.h b/lib/cube/settings.h index 34423a9..4d3a8ac 100644 --- a/lib/cube/settings.h +++ b/lib/cube/settings.h @@ -69,6 +69,17 @@ class Settings bool weatherMetric() const { return _weatherMetric; } void setWeatherMetric(bool value); + /// Seconds between telemetry reports (60 to 86400). + uint32_t telemetryInterval() const { return _telemetryInterval; } + void setTelemetryInterval(uint32_t seconds); + /// Which periodic telemetry reports are sent. + bool reportHealth() const { return _reportHealth; } + void setReportHealth(bool value); + bool reportUsage() const { return _reportUsage; } + void setReportUsage(bool value); + bool reportPerf() const { return _reportPerf; } + void setReportPerf(bool value); + /// Hardware revision written at manufacture (key "HW"), or empty. std::string hardware() const { return _hardware; } @@ -96,6 +107,10 @@ class Settings TICKER = 1 << 9, WEATHER_LOCATION = 1 << 10, WEATHER_METRIC = 1 << 11, + TELEMETRY_INTERVAL = 1 << 12, + REPORT_HEALTH = 1 << 13, + REPORT_USAGE = 1 << 14, + REPORT_PERF = 1 << 15, // the last bit of _dirty }; void changed(Key key); @@ -118,6 +133,10 @@ class Settings std::string _tickerText; std::string _weatherLocation; bool _weatherMetric = false; + uint32_t _telemetryInterval = 300; + bool _reportHealth = true; + bool _reportUsage = true; + bool _reportPerf = true; std::string _hardware; int _legacyPatternIndex = -1; }; diff --git a/lib/cube/telemetry.cpp b/lib/cube/telemetry.cpp new file mode 100644 index 0000000..efeb380 --- /dev/null +++ b/lib/cube/telemetry.cpp @@ -0,0 +1,671 @@ +#include "telemetry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "all_patterns.h" +#include "config.h" + +#if __has_include("secrets.h") +#include "secrets.h" +#endif + +// ESP-IDF's certificate bundle (see updates.h). +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_x509_crt_bundle_end"); + +namespace +{ + const char *TAG = "Telemetry"; + + // Task notification bits. + const uint32_t WAKE_CONNECTED = 1 << 0; + const uint32_t WAKE_SETTINGS = 1 << 1; + const uint32_t WAKE_RESEND_CRASH = 1 << 2; + const uint32_t WAKE_RESTART = 1 << 3; + + // Scanning takes the radio off its channel for a couple of seconds, so + // not with every report. + const uint32_t SCAN_INTERVAL_MS = 60 * 60 * 1000; + const int MAX_SCAN_RESULTS = 30; + + // Raw core dump bytes per message: base64 makes them 4/3 as many. + const size_t CRASH_CHUNK = 3072; + const int MAX_CRASH_CHUNKS = 32; + portMUX_TYPE crashMux = portMUX_INITIALIZER_UNLOCKED; + int crashIds[MAX_CRASH_CHUNKS + 1]; + int crashIdCount = 0; + + String hex32(uint32_t v) + { + char s[11]; + snprintf(s, sizeof(s), "0x%08lx", (unsigned long)v); + return s; + } +} + +void Telemetry::begin(Settings &s, BootLog &log, Renderer &r, CommandHandler h) +{ + settings = &s; + bootLog = &log; + renderer = &r; + handler = h; + usageLock = xSemaphoreCreateMutex(); + usageStartMs = millis(); + + // A random ID, made once: not the MAC, so another cube's topics cannot + // be guessed from the shared login. + prefs.begin("telemetry"); + String id = prefs.getString("id", ""); + if (id.length() != 16) + { + char made[17]; + snprintf(made, sizeof(made), "%08lx%08lx", (unsigned long)esp_random(), (unsigned long)esp_random()); + id = made; + prefs.putString("id", id); + } + strlcpy(deviceId, id.c_str(), sizeof(deviceId)); + base = String("cube/") + deviceId; + +#ifdef MQTT_URL + static String willTopic; + willTopic = base + "/status"; + esp_mqtt_client_config_t cfg = {}; + cfg.broker.address.uri = MQTT_URL; + // Used only for wss:// (and mqtts://); ws:// is for local testing. + cfg.broker.verification.crt_bundle_attach = esp_crt_bundle_attach; +#ifdef MQTT_USER + cfg.credentials.username = MQTT_USER; +#endif +#ifdef MQTT_PASSWORD + cfg.credentials.authentication.password = MQTT_PASSWORD; +#endif + cfg.credentials.client_id = deviceId; + cfg.session.last_will.topic = willTopic.c_str(); + cfg.session.last_will.msg = "offline"; + cfg.session.last_will.qos = 1; + cfg.session.last_will.retain = 1; + cfg.session.keepalive = 60; + cfg.network.reconnect_timeout_ms = 15000; + cfg.network.timeout_ms = 15000; + cfg.buffer.size = 2048; + cfg.buffer.out_size = 6144; // a base64 crash chunk and its JSON + // Below the patterns' workers and well below the render task. + cfg.task.priority = 2; + cfg.task.stack_size = 6144; + client = esp_mqtt_client_init(&cfg); + if (client == nullptr) + { + ESP_LOGE(TAG, "Could not create the MQTT client"); + return; + } + esp_mqtt_client_register_event(client, MQTT_EVENT_ANY, onEvent, this); + xTaskCreate([](void *self) + { static_cast(self)->task(); }, + "Telemetry", 8192, this, 1, &taskHandle); + esp_mqtt_client_start(client); + ESP_LOGI(TAG, "Reporting as %s to %s", deviceId, MQTT_URL); +#else + ESP_LOGI(TAG, "No broker in this build (MQTT_URL); not reporting"); +#endif +} + +Telemetry::Status Telemetry::status() const +{ + Status s; +#ifdef MQTT_URL + s.configured = true; +#endif + s.connected = connected; + s.published = published; + s.failed = failed; + s.commands = commands; + strlcpy(s.deviceId, deviceId, sizeof(s.deviceId)); + return s; +} + +void Telemetry::patternShown(const std::string &id) +{ + if (usageLock == nullptr) + { + return; + } + const uint32_t now = millis(); + xSemaphoreTake(usageLock, portMAX_DELAY); + if (!currentPattern.empty()) + { + usageMs[currentPattern] += now - currentSinceMs; + } + currentPattern = id; + currentSinceMs = now; + xSemaphoreGive(usageLock); +} + +void Telemetry::settingsChanged() +{ + if (taskHandle) + { + xTaskNotify(taskHandle, WAKE_SETTINGS, eSetBits); + } +} + +void Telemetry::onEvent(void *self, esp_event_base_t, int32_t, void *data) +{ + static_cast(self)->handleEvent(static_cast(data)); +} + +/// In the MQTT client's task: quick work only. +void Telemetry::handleEvent(esp_mqtt_event_handle_t event) +{ + switch (event->event_id) + { + case MQTT_EVENT_CONNECTED: + connected = true; + esp_mqtt_client_publish(client, (base + "/status").c_str(), "online", 0, 1, 1); + esp_mqtt_client_subscribe(client, (base + "/set/#").c_str(), 1); + ESP_LOGI(TAG, "Connected"); + if (taskHandle) + { + xTaskNotify(taskHandle, WAKE_CONNECTED, eSetBits); + } + break; + case MQTT_EVENT_DISCONNECTED: + if (connected) + { + ESP_LOGW(TAG, "Disconnected"); + } + connected = false; + break; + case MQTT_EVENT_PUBLISHED: + portENTER_CRITICAL(&crashMux); + for (int i = 0; i < crashIdCount; i++) + { + if (crashIds[i] == event->msg_id) + { + crashIds[i] = crashIds[--crashIdCount]; + crashPending = crashIdCount; + break; + } + } + portEXIT_CRITICAL(&crashMux); + break; + case MQTT_EVENT_DATA: + { + // Commands are small: ignore anything split over several events. + if (event->current_data_offset != 0 || event->data_len != event->total_data_len) + { + break; + } + const String prefix = base + "/set/"; + const String topic(event->topic, event->topic_len); + if (topic.startsWith(prefix)) + { + handleCommand(topic.substring(prefix.length()).c_str(), event->data, event->data_len); + } + break; + } + case MQTT_EVENT_ERROR: + if (event->error_handle && event->error_handle->error_type == MQTT_ERROR_TYPE_CONNECTION_REFUSED) + { + ESP_LOGW(TAG, "Broker refused the connection (%d): check the login", event->error_handle->connect_return_code); + } + break; + default: + break; + } +} + +void Telemetry::handleCommand(const char *name, const char *payload, size_t length) +{ + commands++; + remote::Command cmd; + std::string error; + if (const char *problem = remote::parse(name, payload, length, cmd)) + { + error = problem; + } + else if (cmd.id == remote::Setting::RESTART) + { + xTaskNotify(taskHandle, WAKE_RESTART, eSetBits); + } + else if (cmd.id == remote::Setting::RESEND_CRASH) + { + xTaskNotify(taskHandle, WAKE_RESEND_CRASH, eSetBits); + } + else if (handler) + { + error = handler(cmd); + } + ESP_LOGI(TAG, "set/%s: %s", name, error.empty() ? "ok" : error.c_str()); + + JsonDocument doc; + doc["setting"] = name; + doc["ok"] = error.empty(); + if (!error.empty()) + { + doc["error"] = error; + } + String body; + serializeJson(doc, body); + esp_mqtt_client_publish(client, (base + "/ack").c_str(), body.c_str(), body.length(), 1, 0); + settingsChanged(); +} + +bool Telemetry::publish(const char *subtopic, const String &payload, int qos, bool retain, int *msgId) +{ + if (client == nullptr || !connected) + { + failed++; + return false; + } + const int id = esp_mqtt_client_publish(client, (base + "/" + subtopic).c_str(), payload.c_str(), payload.length(), qos, retain); + if (msgId) + { + *msgId = id; + } + if (id < 0) + { + failed++; + return false; + } + published++; + return true; +} + +void Telemetry::task() +{ + for (;;) + { + uint32_t wake = 0; + xTaskNotifyWait(0, UINT32_MAX, &wake, pdMS_TO_TICKS(1000)); + if (wake & WAKE_RESTART) + { + ESP_LOGW(TAG, "Restart requested over MQTT"); + settings->flush(); + vTaskDelay(pdMS_TO_TICKS(500)); // let the ack go + ESP.restart(); + } + if (!connected) + { + continue; + } + if (!bootSent) + { + // Once a boot, after the first connection. + fetchPublicIp(); + publishBoot(); + publishSettings(); + publishCrash(false); + publishWifiScan(); + bootSent = true; + lastReportMs = lastScanMs = millis(); + } + if (millis() - lastScanMs >= SCAN_INTERVAL_MS) + { + lastScanMs = millis(); + publishWifiScan(); + } + if (wake & WAKE_SETTINGS) + { + publishSettings(); + } + if (wake & WAKE_RESEND_CRASH) + { + if (!publishCrash(true)) + { + publish("ack", "{\"setting\":\"resend_crash\",\"ok\":false,\"error\":\"no core dump stored\"}", 1); + } + } + if (millis() - lastReportMs >= settings->telemetryInterval() * 1000UL) + { + lastReportMs = millis(); + // Catches changes made on the dashboard too. + publishSettings(); + if (settings->reportHealth()) + { + publishHealth(); + } + if (settings->reportUsage()) + { + publishUsage(); + } + if (settings->reportPerf()) + { + publishPerf(); + } + } + } +} + +void Telemetry::fetchPublicIp() +{ + if (publicIp.length()) + { + return; + } + NetworkClientSecure tls; + tls.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); + HTTPClient http; + http.setTimeout(8000); + if (http.begin(tls, "https://api.ipify.org") && http.GET() == HTTP_CODE_OK) + { + publicIp = http.getString(); + publicIp.trim(); + } + http.end(); +} + +void Telemetry::publishBoot() +{ + JsonDocument doc; + const esp_app_desc_t *app = esp_app_get_description(); + doc["version"] = app->version; + doc["fw_version"] = FW_VERSION; +#ifdef FW_TYPE + doc["fw_type"] = FW_TYPE; // stable / beta / build, from CI +#else + doc["fw_type"] = "DEV"; +#endif + doc["built"] = String(app->date) + " " + app->time; + doc["idf"] = app->idf_ver; + char sha[17]; + for (int i = 0; i < 8; i++) + { + snprintf(sha + i * 2, 3, "%02x", app->app_elf_sha256[i]); + } + doc["elf_sha256"] = sha; + esp_chip_info_t chip; + esp_chip_info(&chip); + doc["chip_revision"] = chip.revision; + uint32_t flashSize = 0; + esp_flash_get_size(nullptr, &flashSize); + doc["flash_size"] = flashSize; + doc["psram_size"] = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); + + doc["mac"] = WiFi.macAddress(); + doc["ssid"] = WiFi.SSID(); + doc["bssid"] = WiFi.BSSIDstr(); + doc["rssi"] = WiFi.RSSI(); + doc["channel"] = WiFi.channel(); + doc["ip"] = WiFi.localIP().toString(); + doc["public_ip"] = publicIp; + + doc["reset_reason"] = BootLog::reasonName(uint8_t(esp_reset_reason())); + doc["rolled_back_from"] = bootLog->rolledBackFrom(); + JsonArray boots = doc["boots"].to(); + for (int i = 0; i < BootLog::ENTRIES; i++) + { + const BootLog::Entry *e = bootLog->entry(i); + if (e == nullptr) + { + break; + } + JsonObject b = boots.add(); + b["version"] = e->version; + b["started_by"] = BootLog::reasonName(e->reason); + b["after_update"] = e->afterUpdate != 0; + b["reached"] = BootLog::stageName(e->stage); + } + String body; + serializeJson(doc, body); + publish("boot", body, 1); +} + +void Telemetry::publishSettings() +{ + JsonDocument doc; + doc["pattern"] = settings->pattern(); + doc["brightness"] = settings->brightness(); + doc["ticker"] = settings->tickerText(); + doc["timezone"] = settings->timezone(); + doc["weather_location"] = settings->weatherLocation(); + doc["weather_metric"] = settings->weatherMetric(); + doc["github_updates"] = settings->github(); + doc["development"] = settings->development(); + doc["ota"] = settings->ota(); + doc["telemetry_interval"] = settings->telemetryInterval(); + doc["report_health"] = settings->reportHealth(); + doc["report_usage"] = settings->reportUsage(); + doc["report_perf"] = settings->reportPerf(); + String body; + serializeJson(doc, body); + publish("settings", body, 1, true); +} + +void Telemetry::publishHealth() +{ + JsonDocument doc; + doc["uptime_s"] = millis() / 1000; + doc["free_internal"] = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + doc["min_free_internal"] = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); + doc["largest_internal"] = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + doc["free_psram"] = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + doc["min_free_psram"] = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); + doc["rssi"] = WiFi.RSSI(); + doc["bssid"] = WiFi.BSSIDstr(); // the access point, which can change (roaming, mesh) + doc["channel"] = WiFi.channel(); + doc["pattern"] = settings->pattern(); + doc["published"] = published; + doc["failed"] = failed; + String body; + serializeJson(doc, body); + publish("health", body); +} + +void Telemetry::publishUsage() +{ + JsonDocument doc; + const uint32_t now = millis(); + xSemaphoreTake(usageLock, portMAX_DELAY); + if (!currentPattern.empty()) + { + usageMs[currentPattern] += now - currentSinceMs; + currentSinceMs = now; + } + JsonObject patterns = doc["patterns_s"].to(); + for (const auto &p : usageMs) + { + patterns[p.first] = p.second / 1000; + } + usageMs.clear(); + doc["period_s"] = (now - usageStartMs) / 1000; + usageStartMs = now; + xSemaphoreGive(usageLock); + + doc["brightness"] = settings->brightness(); + doc["spotify_linked"] = Spotify::account().linked; + doc["weather_location_set"] = !settings->weatherLocation().empty(); + doc["ticker_set"] = !settings->tickerText().empty(); + String body; + serializeJson(doc, body); + publish("usage", body); +} + +/// The networks in range, strongest first: names, access points, signal, +/// channel and security. +void Telemetry::publishWifiScan() +{ + const uint32_t start = millis(); + // Blocking, in this task: up to ~120 ms a channel, staying connected. + const int16_t found = WiFi.scanNetworks(false, true, false, 120); + if (found < 0) + { + ESP_LOGW(TAG, "WiFi scan failed (%d)", found); + return; + } + static const char *const AUTH[] = {"open", "wep", "wpa", "wpa2", "wpa/wpa2", "wpa2-enterprise", "wpa3", "wpa2/wpa3", + "wapi", "owe", "wpa3-enterprise-192", "wpa3-ext-psk", "wpa3-ext-psk-mixed", + "dpp", "wpa3-enterprise", "wpa2/wpa3-enterprise"}; + JsonDocument doc; + doc["count"] = found; + doc["scan_ms"] = millis() - start; + doc["connected_bssid"] = WiFi.BSSIDstr(); + JsonArray nets = doc["networks"].to(); + // Results come back strongest first. + for (int i = 0; i < found && i < MAX_SCAN_RESULTS; i++) + { + JsonObject n = nets.add(); + n["ssid"] = WiFi.SSID(i); + n["bssid"] = WiFi.BSSIDstr(i); + n["rssi"] = WiFi.RSSI(i); + n["channel"] = WiFi.channel(i); + const unsigned auth = unsigned(WiFi.encryptionType(i)); + n["auth"] = auth < sizeof(AUTH) / sizeof(AUTH[0]) ? AUTH[auth] : "other"; + } + WiFi.scanDelete(); + String body; + serializeJson(doc, body); + publish("wifi", body); +} + +void Telemetry::publishPerf() +{ + const Renderer::Stats s = renderer->stats(); + JsonDocument doc; + doc["pattern"] = s.pattern; + doc["fps"] = s.windowMs ? s.frames * 1000.0f / s.windowMs : 0.0f; + doc["tick_avg_us"] = s.tickAvgUs; + doc["tick_max_us"] = s.tickMaxUs; + doc["push_avg_us"] = s.pushAvgUs; + doc["push_max_us"] = s.pushMaxUs; + String body; + serializeJson(doc, body); + publish("perf", body); +} + +/** + * The stored core dump, if there is one and it has not been sent: a summary + * on crash, then the raw dump, base64, on crash/. Remembered as sent (by + * CRC) only once the broker has acknowledged every part. + */ +bool Telemetry::publishCrash(bool force) +{ + if (esp_core_dump_image_check() != ESP_OK) + { + ESP_LOGI(TAG, "No core dump stored"); + return false; + } + size_t addr = 0, size = 0; + if (esp_core_dump_image_get(&addr, &size) != ESP_OK || size == 0) + { + ESP_LOGW(TAG, "Could not locate the core dump"); + return false; + } + const int chunks = int((size + CRASH_CHUNK - 1) / CRASH_CHUNK); + if (chunks > MAX_CRASH_CHUNKS) + { + ESP_LOGW(TAG, "Core dump too big to send (%u bytes)", (unsigned)size); + return false; + } + uint8_t *raw = static_cast(heap_caps_malloc(CRASH_CHUNK, MALLOC_CAP_SPIRAM)); + if (raw == nullptr) + { + return false; + } + uint32_t crc = 0; + for (size_t off = 0; off < size; off += CRASH_CHUNK) + { + const size_t n = min(CRASH_CHUNK, size - off); + esp_flash_read(nullptr, raw, addr + off, n); + crc = esp_rom_crc32_le(crc, raw, n); + } + if (!force && prefs.getUInt("crashSent", 0) == crc) + { + ESP_LOGI(TAG, "Core dump %s already sent", hex32(crc).c_str()); + free(raw); + return false; + } + + // The summary: enough to see what crashed without the dump. + esp_core_dump_summary_t *summary = static_cast(heap_caps_calloc(1, sizeof(esp_core_dump_summary_t), MALLOC_CAP_SPIRAM)); + JsonDocument doc; + doc["crc"] = hex32(crc); + doc["size"] = size; + doc["chunks"] = chunks; + if (summary && esp_core_dump_get_summary(summary) == ESP_OK) + { + doc["task"] = summary->exc_task; + doc["pc"] = hex32(summary->exc_pc); + doc["cause"] = summary->ex_info.exc_cause; + doc["vaddr"] = hex32(summary->ex_info.exc_vaddr); + doc["elf_sha256"] = summary->app_elf_sha256; + JsonArray bt = doc["backtrace"].to(); + for (uint32_t i = 0; i < summary->exc_bt_info.depth && i < 16; i++) + { + bt.add(hex32(summary->exc_bt_info.bt[i])); + } + doc["backtrace_corrupted"] = summary->exc_bt_info.corrupted; + } + free(summary); + + portENTER_CRITICAL(&crashMux); + crashIdCount = 0; + portEXIT_CRITICAL(&crashMux); + auto track = [](int id) + { + if (id > 0) + { + portENTER_CRITICAL(&crashMux); + if (crashIdCount < MAX_CRASH_CHUNKS + 1) + { + crashIds[crashIdCount++] = id; + } + portEXIT_CRITICAL(&crashMux); + } + }; + String body; + serializeJson(doc, body); + int id = -1; + bool ok = publish("crash", body, 1, false, &id); + track(id); + + // Base64 of one chunk: 4 * ceil(3072 / 3) + 1. + const size_t encodedSize = 4 * ((CRASH_CHUNK + 2) / 3) + 1; + unsigned char *encoded = static_cast(heap_caps_malloc(encodedSize, MALLOC_CAP_SPIRAM)); + for (int i = 0; ok && encoded && i < chunks; i++) + { + const size_t off = size_t(i) * CRASH_CHUNK; + const size_t n = min(CRASH_CHUNK, size - off); + esp_flash_read(nullptr, raw, addr + off, n); + size_t written = 0; + mbedtls_base64_encode(encoded, encodedSize, &written, raw, n); + String part = String("{\"crc\":\"") + hex32(crc) + "\",\"n\":" + i + ",\"of\":" + chunks + ",\"data\":\"" + + reinterpret_cast(encoded) + "\"}"; + ok = publish((String("crash/") + i).c_str(), part, 1, false, &id); + track(id); + } + free(encoded); + free(raw); + + // Remember it as sent only once the broker has every part. + portENTER_CRITICAL(&crashMux); + crashPending = crashIdCount; + portEXIT_CRITICAL(&crashMux); + const uint32_t start = millis(); + while (ok && crashPending > 0 && millis() - start < 30000) + { + portENTER_CRITICAL(&crashMux); + crashPending = crashIdCount; + portEXIT_CRITICAL(&crashMux); + vTaskDelay(pdMS_TO_TICKS(100)); + } + portENTER_CRITICAL(&crashMux); + const bool allAcked = crashIdCount == 0; + portEXIT_CRITICAL(&crashMux); + if (ok && allAcked) + { + prefs.putUInt("crashSent", crc); + ESP_LOGI(TAG, "Sent core dump %s (%u bytes, %d parts)", hex32(crc).c_str(), (unsigned)size, chunks); + } + return true; +} diff --git a/lib/cube/telemetry.h b/lib/cube/telemetry.h new file mode 100644 index 0000000..2d868f2 --- /dev/null +++ b/lib/cube/telemetry.h @@ -0,0 +1,107 @@ +#ifndef TELEMETRY_H +#define TELEMETRY_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "boot_log.h" +#include "remote.h" +#include "renderer.h" +#include "settings.h" + +/** + * Reports from the cube to an MQTT broker, and settings from the broker to + * the cube. Off unless the build defines MQTT_URL (with MQTT_USER and + * MQTT_PASSWORD) in secrets.h: CI writes it from repository secrets. + * + * Every cube publishes under cube//, where is random, made once + * and kept in NVS, and is also its MQTT client ID -- so the broker can limit + * the shared login to a cube's own topics: + * + * status online / offline (retained; offline is the last will) + * boot once a boot: version, boot log, network, public IP + * health every interval: uptime, memory low-water marks, signal + * usage every interval: time per pattern, brightness, features in use + * perf every interval: the renderer's frame times + * wifi at boot and hourly: the networks in range (a scan) + * crash once per new core dump: a summary, then crash/ chunks + * settings current settings (retained) + * ack the result of each command + * + * and reads cube//set/ (see lib/remote for names and values). + * The shared login should be allowed to publish only those topics and read + * only set/: commands come from a separate admin login. + */ +class Telemetry +{ +public: + /// Applies a setting; returns "" on success or why it failed. + using CommandHandler = std::function; + + void begin(Settings &settings, BootLog &bootLog, Renderer &renderer, CommandHandler handler); + + /// The pattern now showing, for usage reports. Any task. + void patternShown(const std::string &id); + /// Settings changed: republish them. + void settingsChanged(); + + struct Status + { + bool configured = false; // the build has a broker + bool connected = false; + uint32_t published = 0, failed = 0, commands = 0; + char deviceId[17] = ""; + }; + Status status() const; + +private: + static void onEvent(void *self, esp_event_base_t base, int32_t id, void *data); + void handleEvent(esp_mqtt_event_handle_t event); + void handleCommand(const char *name, const char *payload, size_t length); + void task(); + + bool publish(const char *subtopic, const String &payload, int qos = 0, bool retain = false, int *msgId = nullptr); + void publishBoot(); + void publishSettings(); + void publishHealth(); + void publishUsage(); + void publishPerf(); + void publishWifiScan(); + bool publishCrash(bool force); + void fetchPublicIp(); + + Settings *settings = nullptr; + BootLog *bootLog = nullptr; + Renderer *renderer = nullptr; + CommandHandler handler; + Preferences prefs; + + esp_mqtt_client_handle_t client = nullptr; + TaskHandle_t taskHandle = nullptr; + volatile bool connected = false; + bool bootSent = false; + char deviceId[17] = ""; + String base; // "cube/" + String publicIp; + uint32_t lastReportMs = 0; + uint32_t lastScanMs = 0; + volatile uint32_t published = 0, failed = 0, commands = 0; + + // Crash upload: waits for the broker to acknowledge every chunk. + volatile int crashPending = 0; + + // Usage: milliseconds per pattern since the last report. + SemaphoreHandle_t usageLock = nullptr; + std::map usageMs; + std::string currentPattern; + uint32_t currentSinceMs = 0; + uint32_t usageStartMs = 0; +}; + +#endif diff --git a/lib/cube/updates.cpp b/lib/cube/updates.cpp index 6011ec0..f3fac61 100644 --- a/lib/cube/updates.cpp +++ b/lib/cube/updates.cpp @@ -567,10 +567,21 @@ void Updates::checkForUpdates() ESP.restart(); } } - vTaskDelay((CHECK_FOR_UPDATES_INTERVAL * 1000) / portTICK_PERIOD_MS); + // Woken early by checkNow(). + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(CHECK_FOR_UPDATES_INTERVAL * 1000UL)); } } +bool Updates::checkNow() +{ + if (githubTask == nullptr) + { + return false; + } + xTaskNotifyGive(githubTask); + return true; +} + /** * Downloads a release image and installs it, streaming it into OtaWriter. * The first bytes are checked (firmware_image::check) before anything is diff --git a/lib/cube/updates.h b/lib/cube/updates.h index 4e0eb60..bbae083 100644 --- a/lib/cube/updates.h +++ b/lib/cube/updates.h @@ -80,6 +80,9 @@ class Updates void setOta(bool ota); /// Turns GitHub updates on or off and saves the choice. Idempotent. void setGithub(bool github); + /// Checks GitHub now rather than at the next hourly check. False if + /// GitHub updates are off. + bool checkNow(); private: void initFirmwareUpload(); diff --git a/lib/remote/remote.cpp b/lib/remote/remote.cpp new file mode 100644 index 0000000..231146d --- /dev/null +++ b/lib/remote/remote.cpp @@ -0,0 +1,150 @@ +#include "remote.h" + +#include +#include +#include + +namespace +{ + using remote::Setting; + using remote::Type; + + // Ranges and lengths match what the dashboard allows. + const remote::Spec SPECS[] = { + {"pattern", Setting::PATTERN, Type::TEXT, 0, 0, 32}, + {"brightness", Setting::BRIGHTNESS, Type::NUMBER, 0, 255, 0}, + {"ticker", Setting::TICKER, Type::TEXT, 0, 0, 120}, // TICKER_MAX_LENGTH + {"timezone", Setting::TIMEZONE, Type::TEXT, 0, 0, 48}, + {"weather_location", Setting::WEATHER_LOCATION, Type::TEXT, 0, 0, 60}, + {"weather_metric", Setting::WEATHER_METRIC, Type::FLAG, 0, 0, 0}, + {"github_updates", Setting::GITHUB_UPDATES, Type::FLAG, 0, 0, 0}, + {"development", Setting::DEVELOPMENT, Type::FLAG, 0, 0, 0}, + {"ota", Setting::OTA, Type::FLAG, 0, 0, 0}, + {"telemetry_interval", Setting::TELEMETRY_INTERVAL, Type::NUMBER, 60, 86400, 0}, + {"report_health", Setting::REPORT_HEALTH, Type::FLAG, 0, 0, 0}, + {"report_usage", Setting::REPORT_USAGE, Type::FLAG, 0, 0, 0}, + {"report_perf", Setting::REPORT_PERF, Type::FLAG, 0, 0, 0}, + {"restart", Setting::RESTART, Type::ACTION, 0, 0, 0}, + {"check_updates", Setting::CHECK_UPDATES, Type::ACTION, 0, 0, 0}, + {"resend_crash", Setting::RESEND_CRASH, Type::ACTION, 0, 0, 0}, + }; + + bool equalsIgnoreCase(const std::string &a, const char *b) + { + if (a.size() != strlen(b)) + { + return false; + } + for (size_t i = 0; i < a.size(); i++) + { + if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) + { + return false; + } + } + return true; + } +} + +const remote::Spec *remote::specs(size_t &count) +{ + count = sizeof(SPECS) / sizeof(SPECS[0]); + return SPECS; +} + +const remote::Spec *remote::find(const char *name) +{ + if (name == nullptr) + { + return nullptr; + } + for (const Spec &s : SPECS) + { + if (strcmp(s.name, name) == 0) + { + return &s; + } + } + return nullptr; +} + +const char *remote::parse(const char *name, const char *payload, size_t length, Command &out) +{ + const Spec *spec = find(name); + if (spec == nullptr) + { + return "unknown setting"; + } + out = Command(); + out.id = spec->id; + out.type = spec->type; + if (spec->type == Type::ACTION) + { + return nullptr; + } + + std::string value(payload ? payload : "", payload ? length : 0); + // Trim, then unwrap a JSON string. + while (!value.empty() && isspace((unsigned char)value.back())) + { + value.pop_back(); + } + size_t start = 0; + while (start < value.size() && isspace((unsigned char)value[start])) + { + start++; + } + value.erase(0, start); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + { + value = value.substr(1, value.size() - 2); + } + + switch (spec->type) + { + case Type::TEXT: + if (value.size() > spec->maxLength) + { + return "too long"; + } + if (value.find('\0') != std::string::npos) + { + return "contains a NUL"; + } + out.text = value; + return nullptr; + case Type::FLAG: + if (equalsIgnoreCase(value, "true") || equalsIgnoreCase(value, "on") || equalsIgnoreCase(value, "yes") || value == "1") + { + out.flag = true; + return nullptr; + } + if (equalsIgnoreCase(value, "false") || equalsIgnoreCase(value, "off") || equalsIgnoreCase(value, "no") || value == "0") + { + out.flag = false; + return nullptr; + } + return "not true or false"; + case Type::NUMBER: + { + if (value.empty() || value.size() > 12) + { + return "not a number"; + } + char *end = nullptr; + const long n = strtol(value.c_str(), &end, 10); + if (end == nullptr || *end != '\0') + { + return "not a number"; + } + if (n < spec->min || n > spec->max) + { + return "out of range"; + } + out.number = n; + return nullptr; + } + default: + return nullptr; + } +} diff --git a/lib/remote/remote.h b/lib/remote/remote.h new file mode 100644 index 0000000..2a21db1 --- /dev/null +++ b/lib/remote/remote.h @@ -0,0 +1,76 @@ +#ifndef REMOTE_H +#define REMOTE_H + +// The settings and actions a cube accepts from its MQTT broker +// (cube//set/), and the checks every value passes before it is +// applied. Hardware independent (test/test_remote). + +#include +#include + +namespace remote +{ + enum class Setting : unsigned char + { + PATTERN, + BRIGHTNESS, + TICKER, + TIMEZONE, + WEATHER_LOCATION, + WEATHER_METRIC, + GITHUB_UPDATES, + DEVELOPMENT, + OTA, + TELEMETRY_INTERVAL, + REPORT_HEALTH, + REPORT_USAGE, + REPORT_PERF, + RESTART, + CHECK_UPDATES, + RESEND_CRASH, + }; + + enum class Type : unsigned char + { + TEXT, + NUMBER, + FLAG, + ACTION, // the payload is ignored + }; + + struct Spec + { + const char *name; // the topic suffix + Setting id; + Type type; + long min, max; // NUMBER: inclusive range + size_t maxLength; // TEXT + }; + + struct Command + { + Setting id; + Type type; + long number = 0; + bool flag = false; + std::string text; + }; + + /// Every setting and action, for documentation and lookups. + const Spec *specs(size_t &count); + + /// The spec named `name`, or nullptr. + const Spec *find(const char *name); + + /** + * Parses a command: `name` is the topic after ".../set/", `payload` the + * message (not NUL-terminated). Text may be sent bare or as a JSON + * string ("..."); flags as true/false, on/off, yes/no or 1/0; numbers as + * decimal integers within the setting's range. + * + * @return nullptr on success, otherwise why it was rejected. + */ + const char *parse(const char *name, const char *payload, size_t length, Command &out); +} + +#endif diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b3c332e..697cbe7 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -68,4 +68,7 @@ CONFIG_HTTPD_MAX_REQ_HDR_LEN=512 CONFIG_ESP_TLS_PSK_VERIFICATION=y CONFIG_ESP_HTTP_CLIENT_ENABLE_DIGEST_AUTH=y CONFIG_MBEDTLS_HAVE_TIME_DATE=y -CONFIG_MBEDTLS_DEBUG=y +# No CONFIG_MBEDTLS_DEBUG: ESP-IDF's TLS layer (the MQTT client's) turns it +# into a dozen log lines per TLS record, which on the USB-Serial-JTAG console +# block whenever no host is reading. +# CONFIG_MBEDTLS_DEBUG is not set diff --git a/test/test_remote/test_remote.cpp b/test/test_remote/test_remote.cpp new file mode 100644 index 0000000..24b657e --- /dev/null +++ b/test/test_remote/test_remote.cpp @@ -0,0 +1,99 @@ +#include +#include + +#include + +static remote::Command cmd; + +static const char *parse(const char *name, const char *payload) +{ + return remote::parse(name, payload, payload ? strlen(payload) : 0, cmd); +} + +void setUp(void) {} +void tearDown(void) {} + +void test_unknown_settings_are_rejected(void) +{ + TEST_ASSERT_EQUAL_STRING("unknown setting", parse("wifi_password", "x")); + TEST_ASSERT_EQUAL_STRING("unknown setting", parse("", "x")); + TEST_ASSERT_EQUAL_STRING("unknown setting", parse(nullptr, "x")); +} + +void test_numbers_are_checked_against_their_range(void) +{ + TEST_ASSERT_NULL(parse("brightness", "128")); + TEST_ASSERT_EQUAL_INT(128, cmd.number); + TEST_ASSERT_TRUE(cmd.id == remote::Setting::BRIGHTNESS); + TEST_ASSERT_EQUAL_STRING("out of range", parse("brightness", "256")); + TEST_ASSERT_EQUAL_STRING("out of range", parse("brightness", "-1")); + TEST_ASSERT_EQUAL_STRING("not a number", parse("brightness", "12abc")); + TEST_ASSERT_EQUAL_STRING("not a number", parse("brightness", "")); + TEST_ASSERT_EQUAL_STRING("out of range", parse("telemetry_interval", "10")); // under a minute + TEST_ASSERT_NULL(parse("telemetry_interval", " 300 ")); + TEST_ASSERT_EQUAL_INT(300, cmd.number); +} + +void test_flags_accept_the_usual_spellings(void) +{ + const char *yes[] = {"true", "TRUE", "on", "yes", "1", "\"true\""}; + for (const char *v : yes) + { + TEST_ASSERT_NULL(parse("ota", v)); + TEST_ASSERT_TRUE(cmd.flag); + } + const char *no[] = {"false", "Off", "no", "0"}; + for (const char *v : no) + { + TEST_ASSERT_NULL(parse("ota", v)); + TEST_ASSERT_FALSE(cmd.flag); + } + TEST_ASSERT_EQUAL_STRING("not true or false", parse("ota", "maybe")); +} + +void test_text_is_unwrapped_and_limited(void) +{ + TEST_ASSERT_NULL(parse("ticker", "\"Hello, cube\"")); + TEST_ASSERT_EQUAL_STRING("Hello, cube", cmd.text.c_str()); + TEST_ASSERT_NULL(parse("weather_location", " Austin, Texas \n")); + TEST_ASSERT_EQUAL_STRING("Austin, Texas", cmd.text.c_str()); + TEST_ASSERT_NULL(parse("ticker", "")); // clears it + TEST_ASSERT_EQUAL_STRING("", cmd.text.c_str()); + char longText[80]; + memset(longText, 'a', sizeof(longText) - 1); + longText[sizeof(longText) - 1] = '\0'; + TEST_ASSERT_EQUAL_STRING("too long", parse("weather_location", longText)); + const char withNul[] = {'a', '\0', 'b'}; + TEST_ASSERT_EQUAL_STRING("contains a NUL", remote::parse("ticker", withNul, 3, cmd)); +} + +void test_actions_ignore_their_payload(void) +{ + TEST_ASSERT_NULL(parse("restart", "")); + TEST_ASSERT_TRUE(cmd.type == remote::Type::ACTION); + TEST_ASSERT_TRUE(cmd.id == remote::Setting::RESTART); + TEST_ASSERT_NULL(parse("check_updates", "anything")); +} + +void test_every_spec_is_findable(void) +{ + size_t count = 0; + const remote::Spec *all = remote::specs(count); + TEST_ASSERT_EQUAL_INT(16, (int)count); + for (size_t i = 0; i < count; i++) + { + TEST_ASSERT_EQUAL_PTR(&all[i], remote::find(all[i].name)); + } +} + +int main(int, char **) +{ + UNITY_BEGIN(); + RUN_TEST(test_unknown_settings_are_rejected); + RUN_TEST(test_numbers_are_checked_against_their_range); + RUN_TEST(test_flags_accept_the_usual_spellings); + RUN_TEST(test_text_is_unwrapped_and_limited); + RUN_TEST(test_actions_ignore_their_payload); + RUN_TEST(test_every_spec_is_findable); + return UNITY_END(); +} From 86555c3f7fa14df9b18fcce9e7ea0241fe6c19f5 Mon Sep 17 00:00:00 2001 From: Elliot Matson Date: Sun, 4 Oct 2026 15:17:53 -0500 Subject: [PATCH 2/2] Add lost mode, and retain the location-bearing reports Lost mode, from MQTT (lost_message, lost_pin, lost_silent, lost_mode): the cube shows the message on a hidden Lost pattern (unless silent), locks its dashboard and API (423) and firmware uploads (GitHub updates still install), and reports a retained location every two minutes -- a fresh public IP, its network and access point, and a scan. It persists through restarts, power loss and WiFi resets. WiFi setup stays open on purpose: someone setting it up on their network is its likeliest way back online; offline for a few minutes, it also tries open networks, keeping one only if the broker answers through it, without touching the saved network. lost_mode false, or the PIN (a salted hash; five guesses, then one per 15 minutes) on the dashboard, turns it off. boot and wifi are retained now, so a client subscribing later still sees the SSID and scan; health carries the SSID; and every (re)connection publishes a retained network message with a fresh public IP. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + README.md | 23 ++- lib/cube/cube.cpp | 273 ++++++++++++++++++++++++++++++- lib/cube/cube.h | 11 ++ lib/cube/lost_mode.cpp | 216 ++++++++++++++++++++++++ lib/cube/lost_mode.h | 63 +++++++ lib/cube/settings.cpp | 38 ++++- lib/cube/settings.h | 27 ++- lib/cube/telemetry.cpp | 160 +++++++++++++++--- lib/cube/telemetry.h | 23 ++- lib/cube/updates.cpp | 24 ++- lib/cube/updates.h | 6 + lib/cube_utils/cube_utils.h | 2 + lib/patterns/all_patterns.cpp | 1 + lib/patterns/all_patterns.h | 3 +- lib/patterns/lost/lost.cpp | 124 ++++++++++++++ lib/patterns/lost/lost.h | 41 +++++ lib/remote/remote.cpp | 20 +++ lib/remote/remote.h | 8 +- test/test_remote/test_remote.cpp | 15 +- 20 files changed, 1044 insertions(+), 39 deletions(-) create mode 100644 lib/cube/lost_mode.cpp create mode 100644 lib/cube/lost_mode.h create mode 100644 lib/patterns/lost/lost.cpp create mode 100644 lib/patterns/lost/lost.h diff --git a/CLAUDE.md b/CLAUDE.md index d73db27..f9e60a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ lib/cube/updates.* upload card, ArduinoOTA, GitHub updater, update pro lib/cube/settings.* persistent settings (NVS) lib/cube/boot_log.* the last few boots: version, reset reason, how far startup got lib/cube/telemetry.* MQTT reports to the broker and settings from it (see README, Telemetry) +lib/cube/lost_mode.* lost mode's unlock PIN and its search for open networks (Cube::setLost does the rest) lib/cube_utils/ Pattern base class; SinglePanel/BottomPanels views (one ChainView base) lib/cube_geometry/ hardware-free: face mappings, seam stepping, 3D surface mapping, projection lib/life/ hardware-free Game of Life step @@ -169,6 +170,10 @@ scripts/capture_patterns.py --host cube.local ` (frames come from A reset before that point rolls the update back. `BootLog` records every boot's version, reset reason and how far startup got (`/api/v1/boots`, and the Startup card): a failed startup shows the stage it stopped at. +- **Lost mode locks every control.** A new dashboard handler or API POST + needs the `locked()` check its neighbours have, or it becomes a way round + lost mode. Reset WiFi and the setup portal stay open deliberately. The Lost + pattern is `hidden`: never listed, saved or selectable. - **Telemetry credentials are public.** `MQTT_URL`, `MQTT_USER` and `MQTT_PASSWORD` come from the gitignored `secrets.h` (CI writes it from repository secrets), but anything compiled in can be read out of a release. diff --git a/README.md b/README.md index b76664b..d92e659 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,14 @@ Each cube picks a random ID once and publishes under `cube//`, as JSON: | Topic | When | Contents | |---|---|---| | `status` | retained | `online`, or `offline` (the broker publishes that if the cube drops) | -| `boot` | each boot | firmware version and build, chip, MAC address, WiFi network (SSID, BSSID, signal, channel), local and **public IP**, the boot log (reset reasons, rollbacks, how far startup got) | -| `health` | every interval | uptime, memory and its low-water marks, signal, current access point (BSSID), pattern | +| `boot` | each boot (retained) | firmware version and build, chip, MAC address, WiFi network (SSID, BSSID, signal, channel), local and **public IP**, the boot log (reset reasons, rollbacks, how far startup got) | +| `network` | each (re)connection (retained) | SSID, BSSID, signal, local and public IP, and whether it is on its own network or an open one | +| `health` | every interval | uptime, memory and its low-water marks, signal, current network (SSID, BSSID), pattern | | `usage` | every interval | seconds per pattern, brightness, whether Spotify, a weather location and a ticker message are set | | `perf` | every interval | the renderer's frame rate and tick/push times | -| `wifi` | boot, then hourly | a scan of the networks in range: SSID, BSSID, signal, channel, security | +| `wifi` | boot, then hourly (retained) | a scan of the networks in range: SSID, BSSID, signal, channel, security | +| `location` | while lost: every 2 minutes (retained) | a fresh public IP, its network and access point, and a scan of what is in range | +| `lost` | when lost mode changes (retained) | whether it is on, silent, its message, whether a PIN is set | | `crash` | once per crash | a summary (task, PC, cause, backtrace), then the core dump in base64 parts on `crash/` | | `settings` | retained | current settings | | `ack` | per command | whether a command was applied, and why not | @@ -120,8 +123,22 @@ Commands are messages to `cube//set/`: | `weather_metric`, `github_updates`, `development`, `ota` | `true` / `false` | | `telemetry_interval` | seconds, 60-86400 | | `report_health`, `report_usage`, `report_perf` | `true` / `false` | +| `lost_message` | text shown in lost mode (up to 120 characters) | +| `lost_pin` | 4-12 digits that unlock it on its dashboard; empty removes it | +| `lost_silent` | `true`: in lost mode, keep looking normal | +| `lost_mode` | `true` / `false` | | `restart`, `check_updates`, `resend_crash` | anything (actions) | +### Lost mode + +If a cube goes missing, set `lost_message` (and `lost_pin`, if whoever finds it should be able to unlock it), then `lost_mode` `true`: + +- It shows the message, scrolling round the sides, with LOST and its ID on top -- unless `lost_silent` is set, when it keeps showing patterns as usual. +- Its dashboard and API refuse changes (status still reads), and the upload card and ArduinoOTA refuse firmware. GitHub updates still install. +- It reports a `location` every two minutes, and `network` whenever it comes online anywhere. +- WiFi setup stays open on purpose: someone setting it up on their own network is its likeliest way back online. If it is offline for a few minutes, it also tries nearby open networks, keeping one only if the broker can be reached through it; its saved network is never overwritten. +- It persists through restarts, power loss and WiFi resets. `lost_mode` `false`, or the PIN on the dashboard's System tab, turns it off. PIN guesses are limited: after five wrong ones, one every 15 minutes. + ### Broker setup The firmware connects with `wss://` (MQTT over WebSockets, TLS on a standard HTTPS port), checking the certificate against ESP-IDF's bundle, so the broker needs a publicly trusted certificate. Every cube shares one login, which is readable from any published image, so the broker must confine it. With EMQX: diff --git a/lib/cube/cube.cpp b/lib/cube/cube.cpp index 5d1ba1e..859fb1b 100644 --- a/lib/cube/cube.cpp +++ b/lib/cube/cube.cpp @@ -27,6 +27,8 @@ Cube::Cube() : leds(4, USR_LED, NEO_GRB + NEO_KHZ800), spotifyLogout(dashboard, "Log out of Spotify"), bootStatus(dashboard, "Startup", dash::Status::NONE), telemetryStatus(dashboard, "Telemetry", dash::Status::NONE), + lostStatus(dashboard, "Lost mode", dash::Status::NONE), + lostUnlock(dashboard, "Unlock lost mode (PIN)", "Enter the PIN the owner set"), weatherStatus(dashboard, "Weather", dash::Status::NONE), weatherLocation(dashboard, "Weather Location", "City or postcode; blank to use the cube's IP address"), weatherMetric(dashboard, "Weather in \u00b0C and km/h"), @@ -74,7 +76,11 @@ void Cube::init() renderer.begin(dma_display, &server, [this](Pattern *pattern) { - settings.setPattern(pattern->getId()); + // Not the Lost pattern: the saved one is where an unlock returns to. + if (!pattern->isHidden()) + { + settings.setPattern(pattern->getId()); + } telemetry.patternShown(pattern->getId()); dashboard.sendUpdates(); }); bootLog.reached(BootLog::BOOT_PATTERNS); @@ -86,8 +92,12 @@ void Cube::init() updates.begin(server, dma_display, settings, renderer, systemTab); bootLog.reached(BootLog::BOOT_UPDATES); // Reports to the broker, and settings from it, if this build has one. - telemetry.begin(settings, bootLog, renderer, [this](const remote::Command &cmd) + lostMode.begin(settings, [this]() + { return telemetry.connectedNow(); }); + telemetry.begin(settings, bootLog, renderer, lostMode, [this](const remote::Command &cmd) { return applyRemote(cmd); }); + LostPattern::setLabel(telemetry.status().deviceId); + LostPattern::setMessage(settings.lostMessage()); leds.setPixelColor(3, 0, 255, 0); leds.show(); @@ -100,12 +110,22 @@ void Cube::init() int i = 0; for (Pattern *pattern : patternList) { + if (pattern->isHidden()) + { + i++; + continue; + } const std::string name = pattern->getName(); patternButtonLabels.push_back(name + " Pattern"); dash::PushButtonCard *card = new dash::PushButtonCard(dashboard, patternButtonLabels.back().c_str()); ESPDash *dash = &dashboard; card->onPush([this, name, i]() { + if (locked()) + { + refuseLocked(); + return; + } ESP_LOGI("Cube", "Pattern requested: %s", name.c_str()); this->renderer.requestPattern(i); }); // ESP-DASH sorts cards by index, and a Widget's index is not @@ -156,6 +176,16 @@ void Cube::init() } } + // Lost: locked from the start, and showing the owner's message. + if (settings.lostMode()) + { + updates.setLocked(true); + if (!settings.lostSilent()) + { + startIndex = size_t(patternIndex("lost")); + } + } + refreshLostStatus(); renderer.requestPattern(startIndex); // Startup got this far, so the image works: stop the bootloader rolling @@ -171,6 +201,11 @@ void Cube::init() */ void Cube::showPattern(const char *id, bool onlyIfShowing) { + // Only lost mode itself may show something else while it is on. + if (locked() && strcmp(id, "lost") != 0) + { + return; + } if (onlyIfShowing && settings.pattern() != id) { return; @@ -275,15 +310,43 @@ std::string Cube::applyRemote(const remote::Command &cmd) switch (cmd.id) { case Setting::PATTERN: + if (locked()) + { + return "in lost mode: clear lost_mode first"; + } for (Pattern *p : patternList) { - if (p->getId() == cmd.text) + if (p->getId() == cmd.text && !p->isHidden()) { showPattern(cmd.text.c_str()); return ""; } } return "no such pattern"; + case Setting::LOST_MODE: + setLost(cmd.flag, "mqtt"); + break; + case Setting::LOST_SILENT: + settings.setLostSilent(cmd.flag); + if (locked()) + { + // Switch between showing the message and looking normal. + renderer.requestPattern(cmd.flag ? size_t(patternIndex(settings.pattern().c_str())) : size_t(patternIndex("lost"))); + telemetry.lostChanged(); + } + break; + case Setting::LOST_MESSAGE: + settings.setLostMessage(cmd.text); + LostPattern::setMessage(cmd.text); + if (locked()) + { + telemetry.lostChanged(); + } + break; + case Setting::LOST_PIN: + lostMode.setPin(cmd.text); + refreshLostStatus(); + break; case Setting::BRIGHTNESS: setBrightness(uint8_t(cmd.number)); brightnessSlider.setValue(int(cmd.number)); @@ -355,6 +418,76 @@ std::string Cube::applyRemote(const remote::Command &cmd) return ""; } +int Cube::patternIndex(const char *id) +{ + for (size_t p = 0; p < std::size(patternList); p++) + { + if (patternList[p]->getId() == id) + { + return int(p); + } + } + return 0; +} + +/** + * Turns lost mode on or off (see LostMode): saved at once, the dashboard, + * API and firmware uploads locked or unlocked, the message shown (unless + * silent) or the saved pattern brought back, and the broker told. + */ +void Cube::setLost(bool on, const char *source) +{ + ESP_LOGW("Lost", "Lost mode %s (%s)", on ? "on" : "off", source); + settings.setLostMode(on); + settings.flush(); // a power cut right after must not lose it + updates.setLocked(on); + if (on) + { + LostPattern::setMessage(settings.lostMessage()); + if (!settings.lostSilent()) + { + renderer.requestPattern(size_t(patternIndex("lost"))); + } + } + else + { + renderer.requestPattern(size_t(patternIndex(settings.pattern().c_str()))); + } + refreshLostStatus(); + telemetry.lostChanged(); + dashboard.sendUpdates(); +} + +void Cube::refuseLocked() +{ + // Put every control back as it is: the change was not made. + otaToggle.setValue(settings.ota()); + developmentToggle.setValue(settings.development()); + GHUpdateToggle.setValue(settings.github()); + signedFWOnlyToggle.setValue(settings.signedFirmwareOnly()); + brightnessSlider.setValue(settings.brightness()); + latchSlider.setValue(settings.latchBlanking()); + use20MHzToggle.setValue(settings.use20MHz()); + timezoneDropdown.setValue(currentTimezone().name); + tickerInput.setValue(settings.tickerText().c_str()); + weatherLocation.setValue(settings.weatherLocation().c_str()); + weatherMetric.setValue(settings.weatherMetric()); + refreshLostStatus(); + dashboard.sendUpdates(); +} + +void Cube::refreshLostStatus() +{ + if (!locked()) + { + lostStatus.setFeedback("Off", dash::Status::NONE); + return; + } + lostStatus.setFeedback(lostMode.hasPin() ? "This cube is in lost mode. Settings are locked; enter the PIN to unlock it." + : "This cube is in lost mode. Settings are locked; only its owner can unlock it.", + dash::Status::DANGER); +} + void Cube::refreshTelemetryStatus() { const Telemetry::Status t = telemetry.status(); @@ -492,37 +625,72 @@ void Cube::initUI() this->otaToggle.onChange([&](bool state) { + if (locked()) + { + refuseLocked(); + return; + } updates.setOta(state); this->otaToggle.setValue(state); this->dashboard.sendUpdates(); }); this->developmentToggle.onChange([&](bool state) { + if (locked()) + { + refuseLocked(); + return; + } this->setDevelopment(state); this->developmentToggle.setValue(state); this->dashboard.sendUpdates(); }); this->GHUpdateToggle.onChange([&](bool state) { + if (locked()) + { + refuseLocked(); + return; + } updates.setGithub(state); this->GHUpdateToggle.setValue(state); this->dashboard.sendUpdates(); }); this->signedFWOnlyToggle.onChange([&](bool state) { + if (locked()) + { + refuseLocked(); + return; + } this->setSignedFWOnly(state); this->signedFWOnlyToggle.setValue(state); this->dashboard.sendUpdates(); }); brightnessSlider.onChange([&](int value) { + if (locked()) + { + refuseLocked(); + return; + } this->setBrightness(value); this->brightnessSlider.setValue(value); this->dashboard.sendUpdates(); }); latchSlider.onChange([&](int value) { + if (locked()) + { + refuseLocked(); + return; + } this->dma_display->setLatBlanking(value); settings.setLatchBlanking(value); this->latchSlider.setValue(value); this->dashboard.sendUpdates(); }); use20MHzToggle.onChange([&](bool state) { + if (locked()) + { + refuseLocked(); + return; + } settings.setUse20MHz(state); this->use20MHzToggle.setValue(state); this->dashboard.sendUpdates(); }); @@ -541,12 +709,22 @@ void Cube::initUI() this->dashboard.sendUpdates(); }); crashMe.onPush([&]() { + if (locked()) + { + refuseLocked(); + return; + } ESP_LOGI(__func__,"Crashing..."); int *p = NULL; *p = 80; this->dashboard.sendUpdates(); }); timezoneDropdown.onChange([this](const dash::string &name) { + if (locked()) + { + refuseLocked(); + return; + } const timezones::Zone *zone = timezones::find(name.c_str()); if (zone != nullptr) { @@ -566,6 +744,11 @@ void Cube::initUI() spotifyClientId.setValue(Spotify::account().clientId.c_str()); spotifyClientId.onChange([this](const std::optional &value) { + if (locked()) + { + refuseLocked(); + return; + } std::string id = value ? std::string(value->c_str()) : std::string(); while (!id.empty() && isspace((unsigned char)id.back())) id.pop_back(); @@ -578,6 +761,11 @@ void Cube::initUI() spotifyClientSecret.setValue(Spotify::account().hasSecret ? "saved" : ""); spotifyClientSecret.onChange([this](const std::optional &value) { + if (locked()) + { + refuseLocked(); + return; + } std::string secret = value && *value ? std::string(*value) : std::string(); while (!secret.empty() && isspace((unsigned char)secret.back())) secret.pop_back(); @@ -587,6 +775,11 @@ void Cube::initUI() refreshSpotifyStatus(); }); spotifyLogout.onPush([this]() { + if (locked()) + { + refuseLocked(); + return; + } Spotify::logOut(); showPattern("spotify", true); refreshSpotifyStatus(); }); @@ -609,10 +802,20 @@ void Cube::initUI() WeatherPattern::setMetric(settings.weatherMetric()); weatherLocation.setValue(settings.weatherLocation().c_str()); weatherLocation.onChange([this](const std::optional &value) - { setWeatherLocation(value ? std::string(value->c_str()) : std::string()); }); + { + if (locked()) + { + refuseLocked(); + return; + } setWeatherLocation(value ? std::string(value->c_str()) : std::string()); }); weatherMetric.setValue(settings.weatherMetric()); weatherMetric.onChange([this](bool metric) { + if (locked()) + { + refuseLocked(); + return; + } settings.setWeatherMetric(metric); WeatherPattern::setMetric(metric); weatherMetric.setValue(metric); @@ -628,6 +831,11 @@ void Cube::initUI() tickerInput.setValue(settings.tickerText().c_str()); tickerInput.onChange([this](const std::optional &value) { + if (locked()) + { + refuseLocked(); + return; + } std::string text = value ? std::string(value->c_str()) : std::string(); if (text.size() > TICKER_MAX_LENGTH) { @@ -647,6 +855,37 @@ void Cube::initUI() bootStatus.setTab(systemTab); } telemetryStatus.setTab(systemTab); + lostStatus.setTab(systemTab); + lostUnlock.setTab(systemTab); + lostUnlock.onChange([this](const std::optional &value) + { + const std::string pin = value && *value ? std::string(*value) : std::string(); + lostUnlock.setValue(""); + uint32_t wait = 0; + switch (lostMode.tryUnlock(pin, wait)) + { + case LostMode::Unlock::OK: + setLost(false, "pin"); + lostStatus.setFeedback("Unlocked", dash::Status::SUCCESS); + break; + case LostMode::Unlock::WRONG: + lostStatus.setFeedback(wait ? "Wrong PIN. Too many tries: wait 15 minutes." : "Wrong PIN", dash::Status::DANGER); + break; + case LostMode::Unlock::LOCKED_OUT: + { + char text[64]; + snprintf(text, sizeof(text), "Too many tries: wait %lu minutes", (unsigned long)(wait + 59) / 60); + lostStatus.setFeedback(text, dash::Status::DANGER); + break; + } + case LostMode::Unlock::NO_PIN: + lostStatus.setFeedback("No PIN was set: only the owner can unlock this cube", dash::Status::DANGER); + break; + case LostMode::Unlock::NOT_LOST: + lostStatus.setFeedback("Not in lost mode", dash::Status::NONE); + break; + } + dashboard.sendUpdates(); }); this->rebootButton.setTab(systemTab); this->resetWifiButton.setTab(systemTab); @@ -682,6 +921,11 @@ void Cube::initAPI() { request->send(200, "application/json", String("{\"brightness\":") + this->getBrightness() + "}"); }); server.on(uri, HTTP_POST, [&](AsyncWebServerRequest *request) { + if (locked()) + { + request->send(423, "application/json", "{\"error\": \"locked: this cube is in lost mode\"}"); + return; + } //print request ESP_LOGI(__func__,"POST %s", request->url().c_str()); if (request->hasArg("brightness")) @@ -790,6 +1034,10 @@ void Cube::initAPI() JsonArray list = doc["patterns"].to(); for (Pattern *pattern : patternList) { + if (pattern->isHidden()) + { + continue; + } JsonObject p = list.add(); p["id"] = pattern->getId(); p["name"] = pattern->getName(); @@ -799,10 +1047,15 @@ void Cube::initAPI() request->send(200, "application/json", body); }); server.on(uri, HTTP_POST, [&](AsyncWebServerRequest *request) { + if (locked()) + { + request->send(423, "application/json", "{\"error\": \"locked: this cube is in lost mode\"}"); + return; + } const String id = request->hasArg("id") ? request->arg("id") : String(); for (size_t i = 0; i < std::size(patternList); i++) { - if (id == patternList[i]->getId().c_str()) + if (id == patternList[i]->getId().c_str() && !patternList[i]->isHidden()) { renderer.requestPattern(i); request->send(202, "application/json", String("{\"requested\":\"") + id + "\"}"); @@ -859,6 +1112,11 @@ void Cube::initAPI() request->send(200, "application/json", body); }); server.on(uri, HTTP_POST, [&](AsyncWebServerRequest *request) { + if (locked()) + { + request->send(423, "application/json", "{\"error\": \"locked: this cube is in lost mode\"}"); + return; + } if (!request->hasArg("text")) { request->send(400, "application/json", "{\"error\": \"No text parameter\"}"); @@ -912,6 +1170,11 @@ void Cube::initAPI() request->send(200, "application/json", body); }); server.on(uri, HTTP_POST, [&](AsyncWebServerRequest *request) { + if (locked()) + { + request->send(423, "application/json", "{\"error\": \"locked: this cube is in lost mode\"}"); + return; + } if (!request->hasArg("location") && !request->hasArg("metric") && !request->hasArg("preview")) { request->send(400, "application/json", "{\"error\": \"Give location, metric or preview\"}"); diff --git a/lib/cube/cube.h b/lib/cube/cube.h index 927622a..15e106a 100644 --- a/lib/cube/cube.h +++ b/lib/cube/cube.h @@ -36,6 +36,7 @@ #include "settings.h" #include "boot_log.h" #include "telemetry.h" +#include "lost_mode.h" #include "renderer.h" #include "updates.h" #include "timezones.h" @@ -61,6 +62,7 @@ class Cube Settings settings; BootLog bootLog; Telemetry telemetry; + LostMode lostMode; Renderer renderer; std::unordered_map patterns; std::vector patternButtonLabels; @@ -91,6 +93,8 @@ class Cube dash::PushButtonCard spotifyLogout; dash::FeedbackCard<> bootStatus; dash::FeedbackCard<> telemetryStatus; + dash::FeedbackCard<> lostStatus; + dash::PasswordCard lostUnlock; dash::FeedbackCard<> weatherStatus; dash::InputCard weatherLocation; dash::ToggleButtonCard weatherMetric; @@ -124,6 +128,13 @@ class Cube void setTimezone(const timezones::Zone &zone); std::string applyRemote(const remote::Command &cmd); void refreshTelemetryStatus(); + /// Lost mode: the dashboard and API refuse changes while it is on. + bool locked() { return settings.lostMode(); } + void setLost(bool on, const char *source); + /// Puts the dashboard back as it was after refusing a change. + void refuseLocked(); + void refreshLostStatus(); + int patternIndex(const char *id); }; #endif \ No newline at end of file diff --git a/lib/cube/lost_mode.cpp b/lib/cube/lost_mode.cpp new file mode 100644 index 0000000..528d140 --- /dev/null +++ b/lib/cube/lost_mode.cpp @@ -0,0 +1,216 @@ +#include "lost_mode.h" + +#include +#include +#include +#include +#include + +namespace +{ + const char *TAG = "Lost"; + const uint32_t FREE_GUESSES = 5; + const uint32_t LOCKOUT_MS = 15 * 60 * 1000; + const uint32_t OFFLINE_BEFORE_SEARCH_MS = 3 * 60 * 1000; + const int MAX_OPEN_TRIES = 5; +} + +void LostMode::begin(Settings &s, std::function connected) +{ + settings = &s; + brokerConnected = connected; + prefs.begin("lost"); + failures = prefs.getUInt("failures", 0); + // After a restart, a locked-out cube waits a full lockout again. + lastFailureMs = millis(); + xTaskCreate([](void *self) + { static_cast(self)->task(); }, + "Lost", 4096, this, 1, nullptr); +} + +std::string LostMode::hash(const std::string &salt, const std::string &pin) +{ + uint8_t digest[32]; + const std::string input = salt + ":" + pin; + mbedtls_sha256(reinterpret_cast(input.data()), input.size(), digest, 0); + char hex[65]; + for (int i = 0; i < 32; i++) + { + snprintf(hex + i * 2, 3, "%02x", digest[i]); + } + return hex; +} + +void LostMode::setPin(const std::string &pin) +{ + if (pin.empty()) + { + settings->setLostPin(""); + } + else + { + char salt[17]; + snprintf(salt, sizeof(salt), "%08lx%08lx", (unsigned long)esp_random(), (unsigned long)esp_random()); + settings->setLostPin(std::string(salt) + ":" + hash(salt, pin)); + } + failures = 0; + prefs.putUInt("failures", 0); + settings->flush(); +} + +bool LostMode::hasPin() const +{ + return !settings->lostPin().empty(); +} + +LostMode::Unlock LostMode::tryUnlock(const std::string &pin, uint32_t &waitSeconds) +{ + waitSeconds = 0; + if (!settings->lostMode()) + { + return Unlock::NOT_LOST; + } + const std::string stored = settings->lostPin(); + const size_t colon = stored.find(':'); + if (colon == std::string::npos) + { + return Unlock::NO_PIN; + } + if (failures >= FREE_GUESSES && millis() - lastFailureMs < LOCKOUT_MS) + { + waitSeconds = (LOCKOUT_MS - (millis() - lastFailureMs)) / 1000 + 1; + return Unlock::LOCKED_OUT; + } + const std::string expected = stored.substr(colon + 1); + const std::string got = hash(stored.substr(0, colon), pin); + // Constant time: the comparison should not say how much matched. + uint8_t diff = expected.size() != got.size(); + for (size_t i = 0; i < expected.size() && i < got.size(); i++) + { + diff |= uint8_t(expected[i] ^ got[i]); + } + if (diff == 0) + { + failures = 0; + prefs.putUInt("failures", 0); + return Unlock::OK; + } + failures++; + lastFailureMs = millis(); + prefs.putUInt("failures", failures); + ESP_LOGW(TAG, "Wrong unlock PIN (%lu)", (unsigned long)failures); + if (failures >= FREE_GUESSES) + { + waitSeconds = LOCKOUT_MS / 1000; + } + return Unlock::WRONG; +} + +void LostMode::task() +{ + for (;;) + { + vTaskDelay(pdMS_TO_TICKS(30000)); + const bool online = WiFi.status() == WL_CONNECTED; + if (!settings->lostMode() || online) + { + offlineSinceMs = 0; + if (!online) + { + _via = "saved"; + } + continue; + } + // The setup portal is up: someone may be about to connect the cube + // to their own network, the best outcome. Leave it alone. + if (WiFi.getMode() & WIFI_MODE_AP) + { + offlineSinceMs = 0; + continue; + } + if (offlineSinceMs == 0) + { + offlineSinceMs = millis(); + continue; + } + if (millis() - offlineSinceMs < OFFLINE_BEFORE_SEARCH_MS) + { + continue; + } + if (!tryOpenNetworks()) + { + offlineSinceMs = millis(); // wait a while before the next search + } + } +} + +/// Joins the strongest open networks in turn, keeping the first through +/// which the broker answers. Leaves the saved credentials as they were. +bool LostMode::tryOpenNetworks() +{ + wifi_config_t saved = {}; + esp_wifi_get_config(WIFI_IF_STA, &saved); + const String savedSsid(reinterpret_cast(saved.sta.ssid)); + const String savedPassword(reinterpret_cast(saved.sta.password)); + + const int16_t found = WiFi.scanNetworks(false, false, false, 150); + struct Open + { + String ssid; + int32_t rssi; + }; + std::vector open; + for (int i = 0; i < found; i++) + { + if (WiFi.encryptionType(i) == WIFI_AUTH_OPEN && WiFi.SSID(i).length() > 0) + { + open.push_back({WiFi.SSID(i), WiFi.RSSI(i)}); + } + } + WiFi.scanDelete(); + std::sort(open.begin(), open.end(), [](const Open &a, const Open &b) + { return a.rssi > b.rssi; }); + ESP_LOGI(TAG, "Offline while lost: %u open networks in range", (unsigned)open.size()); + + for (int i = 0; i < int(open.size()) && i < MAX_OPEN_TRIES; i++) + { + ESP_LOGI(TAG, "Trying open network %s (%ld dBm)", open[i].ssid.c_str(), (long)open[i].rssi); + WiFi.disconnect(false, false); + // Not persistent: the saved network must survive this. + WiFi.persistent(false); + WiFi.begin(open[i].ssid.c_str()); + WiFi.persistent(true); + uint32_t start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) + { + vTaskDelay(pdMS_TO_TICKS(250)); + } + if (WiFi.status() != WL_CONNECTED) + { + continue; + } + // Connected is not online: captive portals block the broker. + start = millis(); + while (!brokerConnected() && millis() - start < 45000) + { + vTaskDelay(pdMS_TO_TICKS(500)); + } + if (brokerConnected()) + { + _via = String("open:") + open[i].ssid; + ESP_LOGW(TAG, "Reporting through open network %s", open[i].ssid.c_str()); + return true; + } + } + + // Back to the saved network, for when it comes back. + WiFi.disconnect(false, false); + WiFi.persistent(false); + if (savedSsid.length()) + { + WiFi.begin(savedSsid.c_str(), savedPassword.c_str()); + } + WiFi.persistent(true); + _via = "saved"; + return false; +} diff --git a/lib/cube/lost_mode.h b/lib/cube/lost_mode.h new file mode 100644 index 0000000..fbd5a6a --- /dev/null +++ b/lib/cube/lost_mode.h @@ -0,0 +1,63 @@ +#ifndef LOST_MODE_H +#define LOST_MODE_H + +#include +#include +#include +#include + +#include "settings.h" + +/** + * The parts of lost mode that are not the cube's UI: the unlock PIN and the + * search for a way back online. (Cube::setLost turns it on and off, locks + * the dashboard and shows the Lost pattern; Telemetry reports the cube's + * location while it lasts.) + * + * The PIN is kept only as a salted SHA-256, and guesses are rate-limited + * across restarts: after five wrong ones, one try every fifteen minutes. + * + * While lost and offline for a few minutes -- and not showing the WiFi + * setup portal, which is the likeliest way back online -- it tries the + * strongest open networks in range, keeping one only if the broker can be + * reached through it. The saved WiFi credentials are never overwritten. + */ +class LostMode +{ +public: + /// `brokerConnected` says whether telemetry has a connection. + void begin(Settings &settings, std::function brokerConnected); + + /// Stores a new PIN (as a salted hash); empty removes it. + void setPin(const std::string &pin); + bool hasPin() const; + + enum class Unlock + { + OK, + WRONG, + LOCKED_OUT, // too many wrong guesses: wait + NO_PIN, // none set: only MQTT can clear lost mode + NOT_LOST, + }; + /// Checks a PIN typed on the dashboard. Does not clear lost mode itself. + Unlock tryUnlock(const std::string &pin, uint32_t &waitSeconds); + + /// How the cube is online: "saved" network or "open:". + String via() const { return _via; } + +private: + void task(); + bool tryOpenNetworks(); + static std::string hash(const std::string &salt, const std::string &pin); + + Settings *settings = nullptr; + std::function brokerConnected; + Preferences prefs; + uint32_t failures = 0; + uint32_t lastFailureMs = 0; + uint32_t offlineSinceMs = 0; + String _via = "saved"; +}; + +#endif diff --git a/lib/cube/settings.cpp b/lib/cube/settings.cpp index be11468..8145650 100644 --- a/lib/cube/settings.cpp +++ b/lib/cube/settings.cpp @@ -17,6 +17,10 @@ static const char *K_TELEMETRY_INTERVAL = "tmInterval"; static const char *K_REPORT_HEALTH = "tmHealth"; static const char *K_REPORT_USAGE = "tmUsage"; static const char *K_REPORT_PERF = "tmPerf"; +static const char *K_LOST_MODE = "lost"; +static const char *K_LOST_SILENT = "lostSilent"; +static const char *K_LOST_MESSAGE = "lostMsg"; +static const char *K_LOST_PIN = "lostPin"; static const char *K_LEGACY = "cubePrefs"; static const uint64_t FLUSH_DELAY_US = 2 * 1000 * 1000; @@ -57,6 +61,10 @@ bool Settings::begin() _reportHealth = _prefs.getBool(K_REPORT_HEALTH, _reportHealth); _reportUsage = _prefs.getBool(K_REPORT_USAGE, _reportUsage); _reportPerf = _prefs.getBool(K_REPORT_PERF, _reportPerf); + _lostMode = _prefs.getBool(K_LOST_MODE, _lostMode); + _lostSilent = _prefs.getBool(K_LOST_SILENT, _lostSilent); + _lostMessage = _prefs.getString(K_LOST_MESSAGE, "").c_str(); + _lostPin = _prefs.getString(K_LOST_PIN, "").c_str(); if (_prefs.isKey("HW")) { _hardware = _prefs.getString("HW").c_str(); @@ -140,6 +148,10 @@ SETTER(setTelemetryInterval(uint32_t seconds), _telemetryInterval, TELEMETRY_INT SETTER(setReportHealth(bool value), _reportHealth, REPORT_HEALTH, value) SETTER(setReportUsage(bool value), _reportUsage, REPORT_USAGE, value) SETTER(setReportPerf(bool value), _reportPerf, REPORT_PERF, value) +SETTER(setLostMode(bool value), _lostMode, LOST_MODE, value) +SETTER(setLostSilent(bool value), _lostSilent, LOST_SILENT, value) +SETTER(setLostMessage(const std::string &text), _lostMessage, LOST_MESSAGE, text) +SETTER(setLostPin(const std::string &saltedHash), _lostPin, LOST_PIN, saltedHash) #undef SETTER @@ -159,6 +171,22 @@ std::string Settings::tickerText() const return copy; } +std::string Settings::lostMessage() const +{ + xSemaphoreTake(_lock, portMAX_DELAY); + std::string copy = _lostMessage; + xSemaphoreGive(_lock); + return copy; +} + +std::string Settings::lostPin() const +{ + xSemaphoreTake(_lock, portMAX_DELAY); + std::string copy = _lostPin; + xSemaphoreGive(_lock); + return copy; +} + std::string Settings::weatherLocation() const { xSemaphoreTake(_lock, portMAX_DELAY); @@ -178,7 +206,7 @@ std::string Settings::timezone() const void Settings::flush() { xSemaphoreTake(_lock, portMAX_DELAY); - uint16_t dirty = _dirty; + uint32_t dirty = _dirty; _dirty = 0; if (dirty & BRIGHTNESS) _prefs.putUChar(K_BRIGHTNESS, _brightness); @@ -212,6 +240,14 @@ void Settings::flush() _prefs.putBool(K_REPORT_USAGE, _reportUsage); if (dirty & REPORT_PERF) _prefs.putBool(K_REPORT_PERF, _reportPerf); + if (dirty & LOST_MODE) + _prefs.putBool(K_LOST_MODE, _lostMode); + if (dirty & LOST_SILENT) + _prefs.putBool(K_LOST_SILENT, _lostSilent); + if (dirty & LOST_MESSAGE) + _prefs.putString(K_LOST_MESSAGE, _lostMessage.c_str()); + if (dirty & LOST_PIN) + _prefs.putString(K_LOST_PIN, _lostPin.c_str()); if (dirty) { log("Saved settings"); diff --git a/lib/cube/settings.h b/lib/cube/settings.h index 4d3a8ac..d63c28e 100644 --- a/lib/cube/settings.h +++ b/lib/cube/settings.h @@ -80,6 +80,19 @@ class Settings bool reportPerf() const { return _reportPerf; } void setReportPerf(bool value); + /// Lost mode (see LostMode): kept apart from the WiFi credentials, so + /// setting the cube up on another network never clears it. + bool lostMode() const { return _lostMode; } + void setLostMode(bool value); + /// Lost, but looking normal: no message, patterns as usual. + bool lostSilent() const { return _lostSilent; } + void setLostSilent(bool value); + std::string lostMessage() const; + void setLostMessage(const std::string &text); + /// "salt:sha256" of the unlock PIN, or empty for none. + std::string lostPin() const; + void setLostPin(const std::string &saltedHash); + /// Hardware revision written at manufacture (key "HW"), or empty. std::string hardware() const { return _hardware; } @@ -93,7 +106,7 @@ class Settings void log(const char *prefix) const; private: - enum Key : uint16_t + enum Key : uint32_t { BRIGHTNESS = 1 << 0, DEVELOPMENT = 1 << 1, @@ -110,7 +123,11 @@ class Settings TELEMETRY_INTERVAL = 1 << 12, REPORT_HEALTH = 1 << 13, REPORT_USAGE = 1 << 14, - REPORT_PERF = 1 << 15, // the last bit of _dirty + REPORT_PERF = 1 << 15, + LOST_MODE = 1 << 16, + LOST_SILENT = 1 << 17, + LOST_MESSAGE = 1 << 18, + LOST_PIN = 1 << 19, }; void changed(Key key); @@ -119,7 +136,7 @@ class Settings Preferences _prefs; SemaphoreHandle_t _lock = nullptr; esp_timer_handle_t _flushTimer = nullptr; - uint16_t _dirty = 0; + uint32_t _dirty = 0; uint8_t _brightness = 255; bool _development = false; @@ -137,6 +154,10 @@ class Settings bool _reportHealth = true; bool _reportUsage = true; bool _reportPerf = true; + bool _lostMode = false; + bool _lostSilent = false; + std::string _lostMessage; + std::string _lostPin; std::string _hardware; int _legacyPatternIndex = -1; }; diff --git a/lib/cube/telemetry.cpp b/lib/cube/telemetry.cpp index efeb380..a7d9f07 100644 --- a/lib/cube/telemetry.cpp +++ b/lib/cube/telemetry.cpp @@ -33,6 +33,8 @@ namespace const uint32_t WAKE_SETTINGS = 1 << 1; const uint32_t WAKE_RESEND_CRASH = 1 << 2; const uint32_t WAKE_RESTART = 1 << 3; + const uint32_t WAKE_LOST = 1 << 4; + const uint32_t LOCATION_INTERVAL_MS = 2 * 60 * 1000; // Scanning takes the radio off its channel for a couple of seconds, so // not with every report. @@ -54,9 +56,10 @@ namespace } } -void Telemetry::begin(Settings &s, BootLog &log, Renderer &r, CommandHandler h) +void Telemetry::begin(Settings &s, BootLog &log, Renderer &r, LostMode &lost, CommandHandler h) { settings = &s; + lostMode = &lost; bootLog = &log; renderer = &r; handler = h; @@ -159,6 +162,14 @@ void Telemetry::settingsChanged() } } +void Telemetry::lostChanged() +{ + if (taskHandle) + { + xTaskNotify(taskHandle, WAKE_LOST, eSetBits); + } +} + void Telemetry::onEvent(void *self, esp_event_base_t, int32_t, void *data) { static_cast(self)->handleEvent(static_cast(data)); @@ -302,13 +313,37 @@ void Telemetry::task() if (!bootSent) { // Once a boot, after the first connection. - fetchPublicIp(); + fetchPublicIp(true); publishBoot(); + publishNetwork(); publishSettings(); publishCrash(false); publishWifiScan(); bootSent = true; lastReportMs = lastScanMs = millis(); + wake |= WAKE_LOST; // report lost mode at once if it is on + } + else if (wake & WAKE_CONNECTED) + { + // Back online, perhaps somewhere else: say where. + fetchPublicIp(true); + publishNetwork(); + if (settings->lostMode()) + { + wake |= WAKE_LOST; + } + } + if (wake & WAKE_LOST) + { + publishLost(); + if (settings->lostMode()) + { + publishLocation(); + } + } + else if (settings->lostMode() && millis() - lastLocationMs >= LOCATION_INTERVAL_MS) + { + publishLocation(); } if (millis() - lastScanMs >= SCAN_INTERVAL_MS) { @@ -347,9 +382,9 @@ void Telemetry::task() } } -void Telemetry::fetchPublicIp() +void Telemetry::fetchPublicIp(bool force) { - if (publicIp.length()) + if (publicIp.length() && !force) { return; } @@ -362,6 +397,10 @@ void Telemetry::fetchPublicIp() publicIp = http.getString(); publicIp.trim(); } + else if (force) + { + publicIp = ""; // stale: the network may have changed + } http.end(); } @@ -418,7 +457,7 @@ void Telemetry::publishBoot() } String body; serializeJson(doc, body); - publish("boot", body, 1); + publish("boot", body, 1, true); } void Telemetry::publishSettings() @@ -437,6 +476,10 @@ void Telemetry::publishSettings() doc["report_health"] = settings->reportHealth(); doc["report_usage"] = settings->reportUsage(); doc["report_perf"] = settings->reportPerf(); + doc["lost_mode"] = settings->lostMode(); + doc["lost_silent"] = settings->lostSilent(); + doc["lost_message"] = settings->lostMessage(); + doc["lost_pin_set"] = !settings->lostPin().empty(); String body; serializeJson(doc, body); publish("settings", body, 1, true); @@ -452,6 +495,7 @@ void Telemetry::publishHealth() doc["free_psram"] = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); doc["min_free_psram"] = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); doc["rssi"] = WiFi.RSSI(); + doc["ssid"] = WiFi.SSID(); doc["bssid"] = WiFi.BSSIDstr(); // the access point, which can change (roaming, mesh) doc["channel"] = WiFi.channel(); doc["pattern"] = settings->pattern(); @@ -491,28 +535,21 @@ void Telemetry::publishUsage() publish("usage", body); } -/// The networks in range, strongest first: names, access points, signal, -/// channel and security. -void Telemetry::publishWifiScan() +/// Scans (blocking, in this task: up to ~120 ms a channel, staying +/// connected) and adds up to `max` networks, strongest first. Returns how many +/// were found, or a negative error. +int Telemetry::scanInto(JsonArray nets, int max) { - const uint32_t start = millis(); - // Blocking, in this task: up to ~120 ms a channel, staying connected. const int16_t found = WiFi.scanNetworks(false, true, false, 120); if (found < 0) { ESP_LOGW(TAG, "WiFi scan failed (%d)", found); - return; + return found; } static const char *const AUTH[] = {"open", "wep", "wpa", "wpa2", "wpa/wpa2", "wpa2-enterprise", "wpa3", "wpa2/wpa3", "wapi", "owe", "wpa3-enterprise-192", "wpa3-ext-psk", "wpa3-ext-psk-mixed", "dpp", "wpa3-enterprise", "wpa2/wpa3-enterprise"}; - JsonDocument doc; - doc["count"] = found; - doc["scan_ms"] = millis() - start; - doc["connected_bssid"] = WiFi.BSSIDstr(); - JsonArray nets = doc["networks"].to(); - // Results come back strongest first. - for (int i = 0; i < found && i < MAX_SCAN_RESULTS; i++) + for (int i = 0; i < found && i < max; i++) { JsonObject n = nets.add(); n["ssid"] = WiFi.SSID(i); @@ -523,9 +560,94 @@ void Telemetry::publishWifiScan() n["auth"] = auth < sizeof(AUTH) / sizeof(AUTH[0]) ? AUTH[auth] : "other"; } WiFi.scanDelete(); + return found; +} + +/// The networks in range: names, access points, signal, channel, security. +void Telemetry::publishWifiScan() +{ + const uint32_t start = millis(); + JsonDocument doc; + doc["connected_ssid"] = WiFi.SSID(); + doc["connected_bssid"] = WiFi.BSSIDstr(); + const int found = scanInto(doc["networks"].to(), MAX_SCAN_RESULTS); + if (found < 0) + { + return; + } + doc["count"] = found; + doc["scan_ms"] = millis() - start; + String body; + serializeJson(doc, body); + publish("wifi", body, 0, true); +} + +/// Where the cube is online now. Sent on every (re)connection. +void Telemetry::publishNetwork() +{ + JsonDocument doc; + doc["ssid"] = WiFi.SSID(); + doc["bssid"] = WiFi.BSSIDstr(); + doc["rssi"] = WiFi.RSSI(); + doc["channel"] = WiFi.channel(); + doc["ip"] = WiFi.localIP().toString(); + doc["public_ip"] = publicIp; + doc["via"] = lostMode ? lostMode->via() : "saved"; + doc["uptime_s"] = millis() / 1000; + time_t now = time(nullptr); + if (now > 1700000000) + { + doc["time"] = (long long)now; + } + String body; + serializeJson(doc, body); + publish("network", body, 1, true); +} + +/// While lost: everything that could place the cube. A fresh public IP, the +/// network and access point it is on, and what else is in range (nearby +/// access points can be geolocated). +void Telemetry::publishLocation() +{ + lastLocationMs = millis(); + fetchPublicIp(true); + JsonDocument doc; + doc["lost"] = settings->lostMode(); + doc["public_ip"] = publicIp; + doc["ssid"] = WiFi.SSID(); + doc["bssid"] = WiFi.BSSIDstr(); + doc["rssi"] = WiFi.RSSI(); + doc["channel"] = WiFi.channel(); + doc["ip"] = WiFi.localIP().toString(); + doc["via"] = lostMode ? lostMode->via() : "saved"; + doc["uptime_s"] = millis() / 1000; + time_t now = time(nullptr); + if (now > 1700000000) + { + doc["time"] = (long long)now; + } + scanInto(doc["networks"].to(), 15); + String body; + serializeJson(doc, body); + publish("location", body, 1, true); +} + +void Telemetry::publishLost() +{ + JsonDocument doc; + doc["active"] = settings->lostMode(); + doc["silent"] = settings->lostSilent(); + doc["message"] = settings->lostMessage(); + doc["pin_set"] = !settings->lostPin().empty(); + doc["uptime_s"] = millis() / 1000; + time_t now = time(nullptr); + if (now > 1700000000) + { + doc["time"] = (long long)now; + } String body; serializeJson(doc, body); - publish("wifi", body); + publish("lost", body, 1, true); } void Telemetry::publishPerf() diff --git a/lib/cube/telemetry.h b/lib/cube/telemetry.h index 2d868f2..e821808 100644 --- a/lib/cube/telemetry.h +++ b/lib/cube/telemetry.h @@ -2,6 +2,7 @@ #define TELEMETRY_H #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include "boot_log.h" +#include "lost_mode.h" #include "remote.h" #include "renderer.h" #include "settings.h" @@ -25,11 +27,14 @@ * the shared login to a cube's own topics: * * status online / offline (retained; offline is the last will) - * boot once a boot: version, boot log, network, public IP + * boot once a boot: version, boot log, network, public IP (retained) + * network each (re)connection: SSID, BSSID, local and public IP (retained) * health every interval: uptime, memory low-water marks, signal * usage every interval: time per pattern, brightness, features in use * perf every interval: the renderer's frame times - * wifi at boot and hourly: the networks in range (a scan) + * wifi at boot and hourly: the networks in range, a scan (retained) + * location while lost, every 2 minutes: public IP, network, scan (retained) + * lost lost mode's state, when it changes (retained) * crash once per new core dump: a summary, then crash/ chunks * settings current settings (retained) * ack the result of each command @@ -44,12 +49,15 @@ class Telemetry /// Applies a setting; returns "" on success or why it failed. using CommandHandler = std::function; - void begin(Settings &settings, BootLog &bootLog, Renderer &renderer, CommandHandler handler); + void begin(Settings &settings, BootLog &bootLog, Renderer &renderer, LostMode &lostMode, CommandHandler handler); /// The pattern now showing, for usage reports. Any task. void patternShown(const std::string &id); /// Settings changed: republish them. void settingsChanged(); + /// Lost mode was turned on or off: report it, and where the cube is. + void lostChanged(); + bool connectedNow() const { return connected; } struct Status { @@ -73,12 +81,18 @@ class Telemetry void publishUsage(); void publishPerf(); void publishWifiScan(); + void publishNetwork(); + void publishLocation(); + void publishLost(); + /// Scans and adds up to `max` networks, strongest first. + int scanInto(JsonArray nets, int max); bool publishCrash(bool force); - void fetchPublicIp(); + void fetchPublicIp(bool force = false); Settings *settings = nullptr; BootLog *bootLog = nullptr; Renderer *renderer = nullptr; + LostMode *lostMode = nullptr; CommandHandler handler; Preferences prefs; @@ -91,6 +105,7 @@ class Telemetry String publicIp; uint32_t lastReportMs = 0; uint32_t lastScanMs = 0; + uint32_t lastLocationMs = 0; volatile uint32_t published = 0, failed = 0, commands = 0; // Crash upload: waits for the broker to acknowledge every chunk. diff --git a/lib/cube/updates.cpp b/lib/cube/updates.cpp index f3fac61..1954d96 100644 --- a/lib/cube/updates.cpp +++ b/lib/cube/updates.cpp @@ -349,7 +349,7 @@ void Updates::fadeOut() void Updates::setOta(bool ota) { settings->setOta(ota); - if (ota) + if (ota && !locked) { if (otaTask) { @@ -479,6 +479,11 @@ void Updates::initFirmwareUpload() if (index == 0) { ESP_LOGI(__func__, "Firmware upload starting: %s", filename.c_str()); + if (locked) + { + request->send(423, "text/plain", "Locked: this cube is in lost mode"); + return; + } if (updateRequest != nullptr) { request->send(409, "text/plain", "An update is already in progress"); @@ -572,6 +577,23 @@ void Updates::checkForUpdates() } } +void Updates::setLocked(bool lock) +{ + locked = lock; + if (lock && otaTask) + { + // Stop listening, but keep the setting for when it is unlocked. + ESP_LOGI("Updates", "Lost mode: ArduinoOTA off"); + vTaskDelete(otaTask); + otaTask = nullptr; + ArduinoOTA.end(); + } + else if (!lock && settings->ota()) + { + setOta(true); + } +} + bool Updates::checkNow() { if (githubTask == nullptr) diff --git a/lib/cube/updates.h b/lib/cube/updates.h index bbae083..f910708 100644 --- a/lib/cube/updates.h +++ b/lib/cube/updates.h @@ -84,6 +84,11 @@ class Updates /// GitHub updates are off. bool checkNow(); + /// Lost mode: refuse firmware from the upload card and ArduinoOTA, so + /// the cube cannot be wiped over the network. GitHub updates (the + /// owner's own releases) still install. + void setLocked(bool locked); + private: void initFirmwareUpload(); firmware_image::Expected expectedImage(); @@ -135,6 +140,7 @@ class Updates SemaphoreHandle_t animationDone = nullptr; TaskHandle_t otaTask = nullptr; + volatile bool locked = false; TaskHandle_t githubTask = nullptr; }; diff --git a/lib/cube_utils/cube_utils.h b/lib/cube_utils/cube_utils.h index bbdc9e3..379e1d0 100644 --- a/lib/cube_utils/cube_utils.h +++ b/lib/cube_utils/cube_utils.h @@ -39,6 +39,7 @@ struct PatternData { std::string id; // stable, saved in settings; never shown std::string name; // shown on the dashboard + bool hidden = false; // never listed or selectable (the Lost pattern) }; /** @@ -64,6 +65,7 @@ class Pattern /// next one straight away (after a one-tick yield), not by catching up. virtual uint32_t frameInterval() const { return 33; } std::string getName() { return data.name; }; + bool isHidden() const { return data.hidden; }; std::string getId() { return data.id; }; protected: diff --git a/lib/patterns/all_patterns.cpp b/lib/patterns/all_patterns.cpp index f4c7dd1..26e434c 100644 --- a/lib/patterns/all_patterns.cpp +++ b/lib/patterns/all_patterns.cpp @@ -30,4 +30,5 @@ Pattern *patternList[] = { new LavaLamp(), new Aurora(), new Hyperspace(), + new LostPattern(), // hidden: lost mode only }; \ No newline at end of file diff --git a/lib/patterns/all_patterns.h b/lib/patterns/all_patterns.h index a6944fe..7a7a342 100644 --- a/lib/patterns/all_patterns.h +++ b/lib/patterns/all_patterns.h @@ -19,6 +19,7 @@ #include "ticker/ticker.h" #include "dvd/dvd.h" #include "weather/weather_pattern.h" +#include "lost/lost.h" #include "word_clock/word_clock.h" #include "pong/pong_pattern.h" #include "breakout/breakout_pattern.h" @@ -34,7 +35,7 @@ // Array of all patterns. The count is checked against the list in // all_patterns.cpp at compile time. -#define PATTERN_COUNT 26 +#define PATTERN_COUNT 27 extern Pattern *patternList[PATTERN_COUNT]; #endif \ No newline at end of file diff --git a/lib/patterns/lost/lost.cpp b/lib/patterns/lost/lost.cpp new file mode 100644 index 0000000..a0ff84e --- /dev/null +++ b/lib/patterns/lost/lost.cpp @@ -0,0 +1,124 @@ +#include "lost.h" + +#include "fonts.h" + +namespace +{ + SemaphoreHandle_t messageLock() + { + static SemaphoreHandle_t lock = xSemaphoreCreateMutex(); + return lock; + } + std::string message = "This cube is lost. Please contact its owner."; + std::string label; +} + +void LostPattern::setMessage(const std::string &text) +{ + xSemaphoreTake(messageLock(), portMAX_DELAY); + message = text.empty() ? "This cube is lost. Please contact its owner." : text; + xSemaphoreGive(messageLock()); +} + +void LostPattern::setLabel(const std::string &l) +{ + xSemaphoreTake(messageLock(), portMAX_DELAY); + label = l; + xSemaphoreGive(messageLock()); +} + +LostPattern::LostPattern() +{ + data.id = "lost"; + data.name = "Lost"; + data.hidden = true; +} + +LostPattern::~LostPattern() +{ + end(); +} + +void LostPattern::begin(PatternServices *services) +{ + pattern = services; + strip = new BottomPanels(*pattern->display); + top = new SinglePanel(*pattern->display, 0, 0); + strip->setFont(&FreeSansBold18pt7b); + strip->setTextSize(1); + strip->setTextWrap(false); + offset = strip->width(); + textWidth = 0; + shownBlink = -1; +} + +void LostPattern::end() +{ + delete strip; + delete top; + strip = nullptr; + top = nullptr; +} + +/// LOST in red, on or dim, over the cube's short ID. +void LostPattern::drawTop(bool on) +{ + top->fillScreen(0); + top->setFont(&FreeSansBold12pt7b); + top->setTextSize(1); + int16_t x1, y1; + uint16_t w, h; + top->getTextBounds("LOST", 0, 34, &x1, &y1, &w, &h); + top->setTextColor(on ? Canvas::color565(255, 30, 20) : Canvas::color565(70, 8, 5)); + top->setCursor((cube::FACE_SIZE - int16_t(w)) / 2 - x1, 34); + top->print("LOST"); + top->setFont(NULL); + xSemaphoreTake(messageLock(), portMAX_DELAY); + const std::string id = label.substr(0, 8); + xSemaphoreGive(messageLock()); + top->setTextColor(Canvas::color565(150, 150, 150)); + top->setCursor(int16_t((cube::FACE_SIZE - 6 * int(id.size())) / 2), 46); + top->print(id.c_str()); +} + +void LostPattern::tick() +{ + if (strip == nullptr) + { + return; + } + const int blink = (millis() / 600) & 1; + if (blink != shownBlink) + { + drawTop(blink != 0); + shownBlink = blink; + } + + // Scroll the message; pick up a new one each time it has gone by. + if (offset < -textWidth) + { + xSemaphoreTake(messageLock(), portMAX_DELAY); + text = message; + xSemaphoreGive(messageLock()); + int16_t x1, y1; + uint16_t w, h; + strip->getTextBounds(text.c_str(), 0, 44, &x1, &y1, &w, &h); + textWidth = int16_t(w) + x1; + offset = strip->width(); + } + if (text.empty()) + { + xSemaphoreTake(messageLock(), portMAX_DELAY); + text = message; + xSemaphoreGive(messageLock()); + int16_t x1, y1; + uint16_t w, h; + strip->getTextBounds(text.c_str(), 0, 44, &x1, &y1, &w, &h); + textWidth = int16_t(w) + x1; + } + strip->fillRect(0, 0, strip->width(), strip->height(), 0); + strip->setTextColor(Canvas::color565(255, 200, 60)); + strip->setCursor(offset, 44); + strip->print(text.c_str()); + offset--; +} diff --git a/lib/patterns/lost/lost.h b/lib/patterns/lost/lost.h new file mode 100644 index 0000000..d2b36af --- /dev/null +++ b/lib/patterns/lost/lost.h @@ -0,0 +1,41 @@ +#ifndef LOST_H +#define LOST_H + +#include +#include +#include +#include +#include "cube_utils.h" + +/** + * Shown in lost mode (see LostMode), and never listed or selectable + * otherwise: the owner's message scrolling round the side faces in large + * type, and LOST flashing on top with the cube's short ID, so whoever has it + * can quote that when they get in touch. + */ +class LostPattern : public Pattern +{ +public: + LostPattern(); + ~LostPattern(); + void begin(PatternServices *services) override; + void tick() override; + void end() override; + uint32_t frameInterval() const override { return 25; } + + /// The message and the ID shown under LOST. Any task. + static void setMessage(const std::string &text); + static void setLabel(const std::string &label); + +private: + void drawTop(bool on); + + BottomPanels *strip = nullptr; + SinglePanel *top = nullptr; + std::string text; + int16_t offset = 0; + int16_t textWidth = 0; + int shownBlink = -1; +}; + +#endif diff --git a/lib/remote/remote.cpp b/lib/remote/remote.cpp index 231146d..77088dd 100644 --- a/lib/remote/remote.cpp +++ b/lib/remote/remote.cpp @@ -24,6 +24,11 @@ namespace {"report_health", Setting::REPORT_HEALTH, Type::FLAG, 0, 0, 0}, {"report_usage", Setting::REPORT_USAGE, Type::FLAG, 0, 0, 0}, {"report_perf", Setting::REPORT_PERF, Type::FLAG, 0, 0, 0}, + // Lost mode: message and PIN first, then lost_mode true. + {"lost_mode", Setting::LOST_MODE, Type::FLAG, 0, 0, 0}, + {"lost_silent", Setting::LOST_SILENT, Type::FLAG, 0, 0, 0}, + {"lost_message", Setting::LOST_MESSAGE, Type::TEXT, 0, 0, 120}, + {"lost_pin", Setting::LOST_PIN, Type::TEXT, 0, 0, 12, 4, true}, {"restart", Setting::RESTART, Type::ACTION, 0, 0, 0}, {"check_updates", Setting::CHECK_UPDATES, Type::ACTION, 0, 0, 0}, {"resend_crash", Setting::RESEND_CRASH, Type::ACTION, 0, 0, 0}, @@ -107,6 +112,21 @@ const char *remote::parse(const char *name, const char *payload, size_t length, { return "too long"; } + // Empty is allowed whatever the minimum: it clears (a PIN, say). + if (!value.empty() && value.size() < spec->minLength) + { + return "too short"; + } + if (spec->digitsOnly) + { + for (char c : value) + { + if (c < '0' || c > '9') + { + return "digits only"; + } + } + } if (value.find('\0') != std::string::npos) { return "contains a NUL"; diff --git a/lib/remote/remote.h b/lib/remote/remote.h index 2a21db1..29cdc96 100644 --- a/lib/remote/remote.h +++ b/lib/remote/remote.h @@ -25,6 +25,10 @@ namespace remote REPORT_HEALTH, REPORT_USAGE, REPORT_PERF, + LOST_MODE, + LOST_SILENT, + LOST_MESSAGE, + LOST_PIN, RESTART, CHECK_UPDATES, RESEND_CRASH, @@ -43,8 +47,10 @@ namespace remote const char *name; // the topic suffix Setting id; Type type; - long min, max; // NUMBER: inclusive range + long min, max; // NUMBER: inclusive range size_t maxLength; // TEXT + size_t minLength = 0; // TEXT, unless empty (which clears it) + bool digitsOnly = false; // TEXT: 0-9 only (a PIN) }; struct Command diff --git a/test/test_remote/test_remote.cpp b/test/test_remote/test_remote.cpp index 24b657e..ee72876 100644 --- a/test/test_remote/test_remote.cpp +++ b/test/test_remote/test_remote.cpp @@ -79,13 +79,25 @@ void test_every_spec_is_findable(void) { size_t count = 0; const remote::Spec *all = remote::specs(count); - TEST_ASSERT_EQUAL_INT(16, (int)count); + TEST_ASSERT_EQUAL_INT(20, (int)count); for (size_t i = 0; i < count; i++) { TEST_ASSERT_EQUAL_PTR(&all[i], remote::find(all[i].name)); } } +void test_a_pin_is_four_to_twelve_digits(void) +{ + TEST_ASSERT_NULL(parse("lost_pin", "4821")); + TEST_ASSERT_EQUAL_STRING("4821", cmd.text.c_str()); + TEST_ASSERT_NULL(parse("lost_pin", "\"123456789012\"")); + TEST_ASSERT_EQUAL_STRING("too short", parse("lost_pin", "123")); + TEST_ASSERT_EQUAL_STRING("too long", parse("lost_pin", "1234567890123")); + TEST_ASSERT_EQUAL_STRING("digits only", parse("lost_pin", "12a4")); + TEST_ASSERT_NULL(parse("lost_pin", "")); // removes the PIN + TEST_ASSERT_EQUAL_STRING("", cmd.text.c_str()); +} + int main(int, char **) { UNITY_BEGIN(); @@ -95,5 +107,6 @@ int main(int, char **) RUN_TEST(test_text_is_unwrapped_and_limited); RUN_TEST(test_actions_ignore_their_payload); RUN_TEST(test_every_spec_is_findable); + RUN_TEST(test_a_pin_is_four_to_twelve_digits); return UNITY_END(); }