From 2b1ac12ed72188341654a0c0b53ce3725769fa64 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Tue, 13 Oct 2026 01:52:34 +0200 Subject: [PATCH] Add a web UI to the clock firmware - Web UI on port 80: live view of the display, choose the page (clock or a bitmap), bitmaps (list, show, live, edit, delete), draw / image upload with conversion in the browser, live mode, settings, Home Assistant - Live view from a shadow buffer in the BROSE9323 driver (shownPixel) - Settings in NVS with the original behaviour as defaults: schedule times, quarter hours, yawning Pikachu, No WiFi screen, flip time, status LED, Home Assistant broker (restart on change) - Bitmaps on LittleFS; new 320 KB partition, ota slots now 1.31 MB - DisplayController: pages, live mode, settings; app.cpp holds the objects - README for the recovery app; docs for the web UI - Tests: settings, time parsing, bitmap format and names, schedule with settings, pages and live mode (95 total) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 + README.md | 33 +- lib/BROSE9323/BROSE9323.cpp | 30 +- lib/BROSE9323/BROSE9323.h | 2 + partitions.csv | 7 +- platformio.ini | 1 + src/esp32/README.md | 34 ++- src/esp32/app.cpp | 122 ++++++++ src/esp32/app.h | 37 +++ src/esp32/config.h | 2 + src/esp32/display/bitmap_store.cpp | 131 ++++++++ src/esp32/display/bitmap_store.h | 32 ++ src/esp32/display/frames.cpp | 40 ++- src/esp32/display/frames.h | 14 +- src/esp32/display/status_screen.cpp | 9 +- src/esp32/display/status_screen.h | 4 + src/esp32/logic/bitmap_format.cpp | 81 +++++ src/esp32/logic/bitmap_format.h | 26 ++ src/esp32/logic/display_controller.cpp | 119 ++++++-- src/esp32/logic/display_controller.h | 49 ++- src/esp32/logic/schedule.cpp | 20 +- src/esp32/logic/schedule.h | 13 +- src/esp32/logic/settings.cpp | 58 ++++ src/esp32/logic/settings.h | 59 ++++ src/esp32/main.cpp | 30 +- src/esp32/net/home_assistant.cpp | 23 +- src/esp32/net/home_assistant.h | 7 +- src/esp32/system/settings_store.cpp | 40 +++ src/esp32/system/settings_store.h | 7 + src/esp32/web/web_page.h | 188 ++++++++++++ src/esp32/web/web_ui.cpp | 403 +++++++++++++++++++++++++ src/esp32/web/web_ui.h | 22 ++ src/recovery/README.md | 95 ++++++ test/test_esp32/test_main.cpp | 353 ++++++++++++++++++++-- 34 files changed, 1989 insertions(+), 117 deletions(-) create mode 100644 src/esp32/app.cpp create mode 100644 src/esp32/app.h create mode 100644 src/esp32/display/bitmap_store.cpp create mode 100644 src/esp32/display/bitmap_store.h create mode 100644 src/esp32/logic/bitmap_format.cpp create mode 100644 src/esp32/logic/bitmap_format.h create mode 100644 src/esp32/logic/settings.cpp create mode 100644 src/esp32/logic/settings.h create mode 100644 src/esp32/system/settings_store.cpp create mode 100644 src/esp32/system/settings_store.h create mode 100644 src/esp32/web/web_page.h create mode 100644 src/esp32/web/web_ui.cpp create mode 100644 src/esp32/web/web_ui.h create mode 100644 src/recovery/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 272216e..ddb29db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,21 @@ See README.md for the overview. - Arduino's `esp_app_desc_t` version and date come from the precompiled core, so the clock firmware stores `FIRMWARE_VERSION` per slot in NVS for the recovery page. +## ESP32 web UI + +- `app.cpp` owns the objects and the actions (settings, page, live mode, restart); + `web/web_ui.cpp` is the JSON API (endpoints listed in `web_ui.h`), `web/web_page.h` the + single HTML/JS page (raw string literal in PROGMEM). Keep image work in the browser; the + ESP32 only handles 418-character hex bitmaps (`logic/bitmap_format.h`, MSB = left dot). +- Settings: `logic/settings.h` struct with defaults = original behaviour, stored as an NVS + blob with a version (`system/settings_store.cpp`). When the struct changes, bump + `VERSION` there (old records then fall back to defaults). +- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/.bin`, 209 bytes). Names + are restricted (`validBitmapName`), which also makes them safe in paths and JSON. +- The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`). +- Check the page's JavaScript after changes: extract the `)rawliteral"; diff --git a/src/esp32/web/web_ui.cpp b/src/esp32/web/web_ui.cpp new file mode 100644 index 0000000..04a4ef1 --- /dev/null +++ b/src/esp32/web/web_ui.cpp @@ -0,0 +1,403 @@ +#include "web/web_ui.h" + +#include +#include +#include +#include + +#include "app.h" +#include "config.h" +#include "display/bitmap_store.h" +#include "logic/bitmap_format.h" +#include "net/home_assistant.h" +#include "system/boot_target.h" +#include "system/firmware_guard.h" +#include "web/web_page.h" + +static WebServer server(80); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void sendJson(int code, const JsonDocument &doc) +{ + String out; + serializeJson(doc, out); + server.send(code, "application/json", out); +} + +static void sendError(int code, const char *message) +{ + JsonDocument doc; + doc["error"] = message; + sendJson(code, doc); +} + +static void sendOk() +{ + server.send(200, "application/json", "{\"ok\":true}"); +} + +static bool readBody(JsonDocument &doc) +{ + if (deserializeJson(doc, server.arg("plain"))) + { + sendError(400, "Invalid JSON"); + return false; + } + return true; +} + +static void copyText(JsonVariantConst value, char *out, size_t size) +{ + if (!value.is()) + return; + strncpy(out, value.as(), size - 1); + out[size - 1] = '\0'; +} + +static void copyBool(JsonVariantConst value, bool &out) +{ + if (value.is()) + out = value.as(); +} + +static bool copyTime(JsonVariantConst value, uint16_t &out) +{ + if (value.isNull()) + return true; + return parseTime(value.as(), out); +} + +// --------------------------------------------------------------------------- +// Page and status +// --------------------------------------------------------------------------- + +static void handleRoot() +{ + server.send_P(200, "text/html", WEB_PAGE); +} + +static void handleStatus() +{ + JsonDocument doc; + doc["version"] = FIRMWARE_VERSION; + doc["firmware"] = firmwareGuardStatus(); + doc["uptime"] = millis() / 1000; + + struct tm now = {}; + if (getLocalTime(&now, 0)) + { + char time[6]; + formatTime(now.tm_hour * 60 + now.tm_min, time); + doc["time"] = time; + } + + JsonObject wifi = doc["wifi"].to(); + wifi["ssid"] = WiFi.SSID(); + wifi["ip"] = WiFi.localIP().toString(); + wifi["rssi"] = WiFi.RSSI(); + + doc["display_on"] = nano.isOn(); + doc["live"] = controller.liveActive(); + doc["page"] = appSettings().page; + doc["clock_enabled"] = controller.clockEnabled(); + doc["power_enabled"] = controller.powerEnabled(); + + JsonObject ha = doc["ha"].to(); + ha["enabled"] = appSettings().home_assistant.enabled; + ha["connected"] = homeAssistantConnected(); + + size_t used, total; + bitmapStorageUsage(used, total); + JsonObject storage = doc["storage"].to(); + storage["available"] = bitmapStorageAvailable(); + storage["used"] = used; + storage["total"] = total; + + sendJson(200, doc); +} + +// What the dots show now, from the driver's shadow copy +static void handleScreen() +{ + uint8_t bitmap[BITMAP_BYTES] = {}; + for (int y = 0; y < BITMAP_HEIGHT; y++) + for (int x = 0; x < BITMAP_WIDTH; x++) + setBitmapPixel(bitmap, x, y, display.shownPixel(x, y)); + char hex[BITMAP_HEX_LENGTH + 1]; + bitmapToHex(bitmap, hex); + + JsonDocument doc; + doc["on"] = nano.isOn(); + doc["live"] = controller.liveActive(); + doc["data"] = hex; + sendJson(200, doc); +} + +// --------------------------------------------------------------------------- +// Bitmaps +// --------------------------------------------------------------------------- + +// Streamed: with many bitmaps the JSON is too big to build in memory at once +static void handleBitmaps() +{ + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "application/json", ""); + server.sendContent(bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":["); + + bool first = true; + uint8_t bitmap[BITMAP_BYTES]; + char hex[BITMAP_HEX_LENGTH + 1]; + for (const BitmapInfo &info : listBitmaps()) + { + if (!loadBitmap(info.name.c_str(), bitmap)) + continue; + bitmapToHex(bitmap, hex); + // Names only contain letters, digits, space, '-' and '_': no JSON escaping needed + String item = String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" + + (info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}"; + server.sendContent(item); + first = false; + } + server.sendContent("]}"); + server.sendContent(""); +} + +static void handleBitmapGet() +{ + String name = server.arg("name"); + uint8_t bitmap[BITMAP_BYTES]; + if (!loadBitmap(name.c_str(), bitmap)) + { + sendError(404, "Bitmap not found"); + return; + } + char hex[BITMAP_HEX_LENGTH + 1]; + bitmapToHex(bitmap, hex); + JsonDocument doc; + doc["name"] = name; + doc["data"] = hex; + sendJson(200, doc); +} + +static void handleBitmapSave() +{ + JsonDocument body; + if (!readBody(body)) + return; + const char *name = body["name"] | ""; + uint8_t bitmap[BITMAP_BYTES]; + if (!bitmapFromHex(body["data"] | "", bitmap)) + { + sendError(400, "Invalid bitmap data"); + return; + } + const char *error = saveBitmap(name, bitmap); + if (error) + { + sendError(400, error); + return; + } + appBitmapChanged(name); + sendOk(); +} + +static void handleBitmapDelete() +{ + JsonDocument body; + if (!readBody(body)) + return; + const char *name = body["name"] | ""; + const char *error = deleteBitmap(name); + if (error) + { + sendError(400, error); + return; + } + if (strcmp(appSettings().page, name) == 0) + appSelectPage(PAGE_CLOCK); + sendOk(); +} + +// --------------------------------------------------------------------------- +// Page and live mode +// --------------------------------------------------------------------------- + +static void handlePage() +{ + JsonDocument body; + if (!readBody(body)) + return; + const char *error = appSelectPage(body["page"] | ""); + if (error) + sendError(400, error); + else + sendOk(); +} + +static void handleLive() +{ + JsonDocument body; + if (!readBody(body)) + return; + uint8_t bitmap[BITMAP_BYTES]; + if (!bitmapFromHex(body["data"] | "", bitmap)) + { + sendError(400, "Invalid bitmap data"); + return; + } + if (!controller.powerEnabled()) + { + sendError(409, "The display is switched off in Home Assistant (Power switch)"); + return; + } + appShowLive(bitmap); + sendOk(); +} + +static void handleLiveStop() +{ + appStopLive(); + sendOk(); +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +static void settingsToJson(const Settings &s, JsonObject o) +{ + char time[6]; + o["page"] = s.page; + o["flip_time"] = s.flip_time; + o["status_led"] = s.status_led; + + JsonObject schedule = o["schedule"].to(); + schedule["enabled"] = s.schedule.enabled; + formatTime(s.schedule.day_start, time); + schedule["day_start"] = String(time); + formatTime(s.schedule.late_start, time); + schedule["late_start"] = String(time); + schedule["quarter_hours"] = s.schedule.quarter_hours; + formatTime(s.schedule.night_start, time); + schedule["night_start"] = String(time); + + JsonObject clock = o["clock"].to(); + clock["evening_enabled"] = s.clock.evening_enabled; + formatTime(s.clock.evening_start, time); + clock["evening_start"] = String(time); + clock["no_wifi_screen"] = s.clock.no_wifi_screen; + + JsonObject ha = o["home_assistant"].to(); + ha["enabled"] = s.home_assistant.enabled; + ha["host"] = s.home_assistant.host; + ha["port"] = s.home_assistant.port; + ha["user"] = s.home_assistant.user; + ha["password_set"] = s.home_assistant.password[0] != '\0'; +} + +static void handleSettingsGet() +{ + JsonDocument doc; + settingsToJson(appSettings(), doc.to()); + sendJson(200, doc); +} + +// Only the fields in the request change +static void handleSettingsPost() +{ + JsonDocument body; + if (!readBody(body)) + return; + Settings s = appSettings(); + + copyText(body["page"], s.page, sizeof(s.page)); + if (body["flip_time"].is()) + s.flip_time = body["flip_time"].as(); + copyBool(body["status_led"], s.status_led); + + JsonVariantConst schedule = body["schedule"]; + copyBool(schedule["enabled"], s.schedule.enabled); + copyBool(schedule["quarter_hours"], s.schedule.quarter_hours); + JsonVariantConst clock = body["clock"]; + copyBool(clock["evening_enabled"], s.clock.evening_enabled); + copyBool(clock["no_wifi_screen"], s.clock.no_wifi_screen); + if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["late_start"], s.schedule.late_start) || + !copyTime(schedule["night_start"], s.schedule.night_start) || !copyTime(clock["evening_start"], s.clock.evening_start)) + { + sendError(400, "Times must look like 08:30 (00:00 - 24:00)"); + return; + } + + JsonVariantConst ha = body["home_assistant"]; + copyBool(ha["enabled"], s.home_assistant.enabled); + copyText(ha["host"], s.home_assistant.host, sizeof(s.home_assistant.host)); + if (ha["port"].is()) + s.home_assistant.port = ha["port"].as(); + copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user)); + copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password)); + + bool restart = false; + const char *error = appSaveSettings(s, restart); + if (error) + { + sendError(400, error); + return; + } + + JsonDocument doc; + settingsToJson(appSettings(), doc["settings"].to()); + doc["restart"] = restart; + sendJson(200, doc); + if (restart) + appRestartLater(1000); +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +static void handleRestart() +{ + sendOk(); + appRestartLater(500); +} + +static void handleRecovery() +{ + if (!recoveryAvailable()) + { + sendError(409, "No recovery app installed (see README, one-time USB installation)"); + return; + } + sendOk(); + delay(300); // let the response reach the browser + rebootToRecovery(RecoveryReason::Manual); +} + +void webUiBegin() +{ + server.on("/", HTTP_GET, handleRoot); + server.on("/api/status", HTTP_GET, handleStatus); + server.on("/api/screen", HTTP_GET, handleScreen); + server.on("/api/bitmaps", HTTP_GET, handleBitmaps); + server.on("/api/bitmap", HTTP_GET, handleBitmapGet); + server.on("/api/bitmap", HTTP_POST, handleBitmapSave); + server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete); + server.on("/api/page", HTTP_POST, handlePage); + server.on("/api/live", HTTP_POST, handleLive); + server.on("/api/live/stop", HTTP_POST, handleLiveStop); + server.on("/api/settings", HTTP_GET, handleSettingsGet); + server.on("/api/settings", HTTP_POST, handleSettingsPost); + server.on("/api/restart", HTTP_POST, handleRestart); + server.on("/api/recovery", HTTP_POST, handleRecovery); + server.onNotFound([]() { sendError(404, "Not found"); }); + server.begin(); +} + +void webUiLoop() +{ + server.handleClient(); +} diff --git a/src/esp32/web/web_ui.h b/src/esp32/web/web_ui.h new file mode 100644 index 0000000..b308549 --- /dev/null +++ b/src/esp32/web/web_ui.h @@ -0,0 +1,22 @@ +#pragma once + +// Web UI of the clock firmware on port 80 (http:///): live view of the display, +// choosing the page, bitmaps (upload, draw, store), live mode, settings and Home +// Assistant. The page itself is in web/web_page.h. +// +// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h): +// GET /api/status time, WiFi, firmware, Home Assistant, storage +// GET /api/screen what the dots show now {on, live, data} +// GET /api/bitmaps all bitmaps with data {storage, bitmaps:[{name, builtin, data}]} +// GET /api/bitmap?name= one bitmap {name, data} +// POST /api/bitmap store {name, data} +// POST /api/bitmap/delete {name} +// POST /api/page {page: "clock" or a bitmap name} +// POST /api/live show now {data} +// POST /api/live/stop +// GET /api/settings (the MQTT password is never sent, only password_set) +// POST /api/settings changed fields; {settings, restart} +// POST /api/restart +// POST /api/recovery +void webUiBegin(); +void webUiLoop(); diff --git a/src/recovery/README.md b/src/recovery/README.md new file mode 100644 index 0000000..4084e98 --- /dev/null +++ b/src/recovery/README.md @@ -0,0 +1,95 @@ +# ESP32: recovery app + +PlatformIO environment: `recovery` (USB only) + +A small second app on the ESP32 that only does WiFi, a setup web page and OTA. It lives in +the `factory` partition (see [partitions.csv](../../partitions.csv)), so OTA updates of +the clock firmware never change it. Its job: the ESP32 stays reachable, whatever happens +to the clock firmware, so it never needs USB again. + +Installing it and using it as a user is described in the +[main README](../../README.md#recovery-app). This file is about how it works. + +## Files + +| File | Contents | +|------|----------| +| `main.cpp` | `setup()` / `loop()`: WiFi (station + setup access point), OTA, status on the display, when to start the clock firmware again | +| `web_portal.*` | Setup page on port 80 and the captive portal (DNS) of the setup WiFi | +| `recovery_state.h` | State shared by `main.cpp` and the web portal | + +It also uses code from the clock firmware (see `build_src_filter` of the `recovery` +environment in `platformio.ini`): + +| From `src/esp32/` | Used for | +|-------------------|----------| +| `logic/boot_policy.*` | Recovery reasons, when to start the clock firmware again (tested in `test/test_esp32`) | +| `system/system_store.*` | NVS: WiFi credentials, recovery reason, clock firmware slot and version | +| `system/boot_target.*` | Starting the clock firmware in `ota_0` / `ota_1` | +| `nano_link/nano_link.*` | Relay and serial link to the Nano, for the status messages | +| `display/status_screen.*`, `display/text.*` | Two-line status messages on the display | + +## How it starts + +The clock firmware calls `rebootToRecovery(reason)`: it stores the reason in NVS, selects +the `factory` partition (this erases otadata) and restarts. The bootloader also falls back +to the recovery app by itself when neither `ota_0` nor `ota_1` holds a working firmware. + +| Reason | Set by | Back to the clock firmware | +|--------|--------|----------------------------| +| `Wifi` | clock firmware: no WiFi at boot (30 s) or for 10 min | when WiFi works, after at least 1 min | +| `Crash` | clock firmware: 3 crashes in a row | after 10 min | +| `Manual` | Home Assistant button, web UI "Recovery app" | after 10 min | +| `PowerCycle` | clock firmware: 3 quick power cycles | after 10 min | +| `None` | bootloader fallback, or a fresh install | after 10 min, if a clock firmware exists | + +"Stay in recovery" on the setup page turns the automatic start off; "Start the clock +firmware now" starts it right away. The decision is `recoveryShouldStartMain()` in +`logic/boot_policy.cpp`. + +`startMainFirmware()` selects the slot that ran last (stored in NVS by the clock firmware), +or the other one. `esp_ota_set_boot_partition()` checks the whole image first, so a +broken slot is skipped. The clock firmware then starts as `PENDING_VERIFY` and must pass +the 60 s check (see the [ESP32 README](../esp32/README.md#keeping-ota-alive)), or the +bootloader comes back here. + +## WiFi + +- **Station:** the WiFi from NVS (set on the setup page), or `WIFI_SSID` / `WIFI_PASSWORD` + from `src/esp32/secrets.h`. Retried every 30 s; auto reconnect is off so the retries + are under control. +- **Setup access point** `FlipDot-Setup` (password `SETUP_AP_PASSWORD`): opened when the + station hasn't connected for 20 s. While a phone is connected to it, the station + retries pause, because they make the access point unstable. Closed 2 minutes after + the station works again (if nobody is connected). +- **Captive portal:** while the access point is open, a DNS server answers every name + with 192.168.4.1, and unknown pages redirect to the setup page, so phones open it by + themselves. + +## Setup page + +`GET /` shows the reason, WiFi state, the clock firmware in both slots and when it starts +again. `POST /wifi` stores new WiFi credentials and connects, `POST /start` starts the clock +firmware, `POST /stay` turns the automatic start off. There is no login: on your own +WiFi it has the same trust as OTA, on the setup WiFi the WiFi password protects it. + +## Display + +`StatusScreen` shows `Recovery` + reason, `WiFi setup:` + the access point name, or +`Recovery` + the IP address, whichever applies. After each message the display is switched +off again; the dots keep the image. New messages at most every 30 s. + +## OTA + +ArduinoOTA works on both networks, like in the clock firmware: +`pio run -e esp32 -t upload` (add `--upload-port 192.168.4.1` on the setup WiFi). An upload +from here always goes to `ota_0` (the first OTA slot after `factory`) and starts right after. + +## Safety + +- A task watchdog (60 s) restarts the app when `loop()` hangs. After that restart the + bootloader starts the recovery app again (otadata still points to `factory`). +- The watchdog is paused during an OTA upload. +- Keep this app small and simple: it is the last line of defence, and changing it needs + USB (`pio run -e recovery -t upload`). It must fit in the 1 MB `factory` partition + (about 840 KB now). diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index f9ce504..2815d2b 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -12,10 +12,12 @@ #include #include "config.h" +#include "logic/bitmap_format.h" #include "logic/boot_policy.h" #include "logic/display_controller.h" #include "logic/power_timer.h" #include "logic/schedule.h" +#include "logic/settings.h" #include "logic/status_led.h" // --------------------------------------------------------------------------- @@ -47,12 +49,14 @@ class FakeFrames : public Frames public: std::vector shown; - void clock(int hour, int minute) override + void clock(int hour, int minute, bool evening) override { - char s[16]; - snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute); + char s[24]; + snprintf(s, sizeof(s), "clock %02d:%02d%s", hour, minute, evening ? " evening" : ""); shown.push_back(s); } + void page() override { shown.push_back("page"); } + void live() override { shown.push_back("live"); } void noWifi(int hour, int minute) override { char s[16]; @@ -65,6 +69,8 @@ public: std::string last() const { return shown.empty() ? "" : shown.back(); } }; +static const Settings defaults; + static FakePower power; static FakeFrames frames; static DisplayController *controller; @@ -100,42 +106,42 @@ static void run(int hour, int minute, uint32_t ms = 0) void test_schedule_night_until_8() { - TEST_ASSERT_TRUE(scheduleFor(0, 0) == ScheduleMode::Night); - TEST_ASSERT_TRUE(scheduleFor(7, 0) == ScheduleMode::Night); - TEST_ASSERT_TRUE(scheduleFor(7, 59) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(0, 0, defaults.schedule) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(7, 0, defaults.schedule) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(7, 59, defaults.schedule) == ScheduleMode::Night); } void test_schedule_clock_from_8_to_22() { - TEST_ASSERT_TRUE(scheduleFor(8, 0) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(12, 34) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(21, 59) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(8, 0, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(12, 34, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(21, 59, defaults.schedule) == ScheduleMode::Clock); } void test_schedule_quarter_hours_until_23_30() { - TEST_ASSERT_TRUE(scheduleFor(22, 0) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(22, 1) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(22, 14) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(22, 15) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(22, 45) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(23, 0) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(23, 29) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(23, 30) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(22, 0, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Standby); + TEST_ASSERT_TRUE(scheduleFor(22, 14, defaults.schedule) == ScheduleMode::Standby); + TEST_ASSERT_TRUE(scheduleFor(22, 15, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(22, 45, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(23, 0, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Standby); + TEST_ASSERT_TRUE(scheduleFor(23, 30, defaults.schedule) == ScheduleMode::Clock); } void test_schedule_night_from_23_31() { - TEST_ASSERT_TRUE(scheduleFor(23, 31) == ScheduleMode::Night); - TEST_ASSERT_TRUE(scheduleFor(23, 45) == ScheduleMode::Night); - TEST_ASSERT_TRUE(scheduleFor(23, 59) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(23, 31, defaults.schedule) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(23, 45, defaults.schedule) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night); } void test_evening_from_17() { - TEST_ASSERT_FALSE(isEvening(16)); - TEST_ASSERT_TRUE(isEvening(17)); - TEST_ASSERT_TRUE(isEvening(23)); + TEST_ASSERT_FALSE(isEvening(16, 59, defaults.clock)); + TEST_ASSERT_TRUE(isEvening(17, 0, defaults.clock)); + TEST_ASSERT_TRUE(isEvening(23, 0, defaults.clock)); } // --------------------------------------------------------------------------- @@ -376,7 +382,7 @@ void test_standby_keeps_the_last_time_and_powers_off() run(22, 0); run(22, 1, POWER_OFF_DELAY + 200); TEST_ASSERT_FALSE(power.is_on); - TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no blank + TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no blank } void test_quarter_hour_powers_on_again_and_redraws() @@ -386,7 +392,7 @@ void test_quarter_hour_powers_on_again_and_redraws() TEST_ASSERT_FALSE(power.is_on); run(22, 15); TEST_ASSERT_TRUE(power.is_on); - TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str()); + TEST_ASSERT_EQUAL_STRING("clock 22:15 evening", frames.last().c_str()); } void test_clock_cancels_pending_power_off() @@ -515,6 +521,277 @@ void test_night_without_wifi_still_powers_off() TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); } +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +void test_default_settings_are_valid() +{ + TEST_ASSERT_NULL(validateSettings(defaults)); +} + +void test_settings_times_must_be_in_order() +{ + Settings s; + s.schedule.late_start = 7 * 60; // before day_start + TEST_ASSERT_NOT_NULL(validateSettings(s)); + s = Settings(); + s.schedule.night_start = 21 * 60; // before late_start + TEST_ASSERT_NOT_NULL(validateSettings(s)); + s = Settings(); + s.schedule.night_start = MINUTES_PER_DAY; // never night: fine + TEST_ASSERT_NULL(validateSettings(s)); + s.schedule.night_start = MINUTES_PER_DAY + 1; + TEST_ASSERT_NOT_NULL(validateSettings(s)); +} + +void test_settings_flip_time_and_port_ranges() +{ + Settings s; + s.flip_time = 49; + TEST_ASSERT_NOT_NULL(validateSettings(s)); + s.flip_time = 2001; + TEST_ASSERT_NOT_NULL(validateSettings(s)); + s = Settings(); + s.home_assistant.port = 0; + TEST_ASSERT_NOT_NULL(validateSettings(s)); +} + +void test_settings_page_must_be_clock_or_bitmap_name() +{ + Settings s; + strcpy(s.page, "My bitmap"); + TEST_ASSERT_NULL(validateSettings(s)); + strcpy(s.page, "../evil"); + TEST_ASSERT_NOT_NULL(validateSettings(s)); +} + +void test_time_parse_and_format() +{ + uint16_t m = 0; + TEST_ASSERT_TRUE(parseTime("08:30", m)); + TEST_ASSERT_EQUAL(510, m); + TEST_ASSERT_TRUE(parseTime("24:00", m)); + TEST_ASSERT_EQUAL(MINUTES_PER_DAY, m); + TEST_ASSERT_FALSE(parseTime("24:01", m)); + TEST_ASSERT_FALSE(parseTime("12:60", m)); + TEST_ASSERT_FALSE(parseTime("noon", m)); + TEST_ASSERT_FALSE(parseTime("12:30x", m)); + TEST_ASSERT_FALSE(parseTime(nullptr, m)); + char out[6]; + formatTime(23 * 60 + 31, out); + TEST_ASSERT_EQUAL_STRING("23:31", out); + formatTime(MINUTES_PER_DAY, out); + TEST_ASSERT_EQUAL_STRING("24:00", out); +} + +// --------------------------------------------------------------------------- +// Bitmap format +// --------------------------------------------------------------------------- + +void test_bitmap_is_84_by_19_in_drawbitmap_format() +{ + TEST_ASSERT_EQUAL(11, BITMAP_ROW_BYTES); + TEST_ASSERT_EQUAL(209, BITMAP_BYTES); + uint8_t b[BITMAP_BYTES] = {}; + setBitmapPixel(b, 0, 0, true); + TEST_ASSERT_EQUAL_HEX8(0x80, b[0]); // leftmost dot = most significant bit + setBitmapPixel(b, 83, 18, true); + TEST_ASSERT_EQUAL_HEX8(0x10, b[18 * 11 + 10]); // x = 83: bit 3 of byte 10 + TEST_ASSERT_TRUE(bitmapPixel(b, 83, 18)); + TEST_ASSERT_FALSE(bitmapPixel(b, 84, 0)); // outside + setBitmapPixel(b, 0, 0, false); + TEST_ASSERT_EQUAL_HEX8(0x00, b[0]); +} + +void test_bitmap_hex_round_trip() +{ + uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES]; + for (int i = 0; i < BITMAP_BYTES; i++) + a[i] = (uint8_t)(i * 37); + char hex[BITMAP_HEX_LENGTH + 1]; + bitmapToHex(a, hex); + TEST_ASSERT_EQUAL(BITMAP_HEX_LENGTH, (int)strlen(hex)); + TEST_ASSERT_TRUE(bitmapFromHex(hex, b)); + TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES); +} + +void test_bitmap_hex_rejects_wrong_input() +{ + uint8_t b[BITMAP_BYTES]; + TEST_ASSERT_FALSE(bitmapFromHex("abcd", b)); + TEST_ASSERT_FALSE(bitmapFromHex(nullptr, b)); + std::string bad(BITMAP_HEX_LENGTH, 'f'); + bad[100] = 'x'; + TEST_ASSERT_FALSE(bitmapFromHex(bad.c_str(), b)); + std::string upper(BITMAP_HEX_LENGTH, 'F'); + TEST_ASSERT_TRUE(bitmapFromHex(upper.c_str(), b)); +} + +void test_bitmap_names() +{ + TEST_ASSERT_TRUE(validBitmapName("Nintendo")); + TEST_ASSERT_TRUE(validBitmapName("Happy Birthday 2")); + TEST_ASSERT_TRUE(validBitmapName("a-b_c")); + TEST_ASSERT_FALSE(validBitmapName("")); + TEST_ASSERT_FALSE(validBitmapName(" lead")); + TEST_ASSERT_FALSE(validBitmapName("trail ")); + TEST_ASSERT_FALSE(validBitmapName("../etc")); + TEST_ASSERT_FALSE(validBitmapName("a/b")); + TEST_ASSERT_FALSE(validBitmapName("Clock")); // the clock page + TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234")); + TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345")); +} + +// --------------------------------------------------------------------------- +// Schedule with settings +// --------------------------------------------------------------------------- + +void test_schedule_disabled_is_always_on() +{ + ScheduleSettings s; + s.enabled = false; + TEST_ASSERT_TRUE(scheduleFor(3, 0, s) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock); +} + +void test_schedule_custom_times() +{ + ScheduleSettings s; + s.day_start = 6 * 60 + 30; + s.late_start = 20 * 60; + s.night_start = 21 * 60; + TEST_ASSERT_TRUE(scheduleFor(6, 29, s) == ScheduleMode::Night); + TEST_ASSERT_TRUE(scheduleFor(6, 30, s) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Standby); + TEST_ASSERT_TRUE(scheduleFor(20, 30, s) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(21, 0, s) == ScheduleMode::Night); +} + +void test_schedule_without_quarter_hours_stays_on_until_night() +{ + ScheduleSettings s; + s.quarter_hours = false; + TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night); +} + +void test_evening_can_be_disabled() +{ + ClockSettings c; + c.evening_enabled = false; + TEST_ASSERT_FALSE(isEvening(20, 0, c)); +} + +// --------------------------------------------------------------------------- +// Pages and live mode +// --------------------------------------------------------------------------- + +void test_bitmap_page_is_shown_once() +{ + controller->setPage(Page::Bitmap); + run(12, 0); + run(12, 1, 5000); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); + TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str()); +} + +void test_bitmap_page_redrawn_when_changed() +{ + controller->setPage(Page::Bitmap); + run(12, 0); + controller->pageChanged(); + run(12, 0); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); +} + +void test_switching_back_to_the_clock_page() +{ + controller->setPage(Page::Bitmap); + run(12, 0); + controller->setPage(Page::Clock); + run(12, 0); + TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str()); +} + +void test_bitmap_page_follows_the_schedule() +{ + controller->setPage(Page::Bitmap); + run(23, 30); + run(23, 31, POWER_OFF_DELAY + 200); + TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); + TEST_ASSERT_FALSE(power.is_on); +} + +void test_evening_clock_redrawn_when_the_evening_starts() +{ + run(16, 59); + run(17, 0); + TEST_ASSERT_EQUAL_STRING("clock 17:00 evening", frames.last().c_str()); +} + +void test_settings_change_redraws() +{ + run(18, 0); + ClockSettings c; + c.evening_enabled = false; + controller->setSettings(defaults.schedule, c); + run(18, 0); + TEST_ASSERT_EQUAL_STRING("clock 18:00", frames.last().c_str()); +} + +void test_live_is_shown_right_away_even_at_night() +{ + run(3, 0); + controller->showLive(now); + run(3, 0); + TEST_ASSERT_TRUE(power.is_on); + TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str()); +} + +void test_live_works_before_the_time_is_synced() +{ + controller->showLive(now); + controller->update(now, false, 0, 0, true); + TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str()); +} + +void test_each_live_image_is_drawn() +{ + controller->showLive(now); + run(12, 0); + controller->showLive(now); + run(12, 0); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); +} + +void test_stop_live_returns_to_the_page() +{ + controller->showLive(now); + run(12, 0); + controller->stopLive(); + run(12, 0); + TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str()); + TEST_ASSERT_FALSE(controller->liveActive()); +} + +void test_live_ends_after_timeout() +{ + controller->showLive(now); + run(12, 0, LIVE_TIMEOUT + 200); + TEST_ASSERT_FALSE(controller->liveActive()); + TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str()); +} + +void test_live_respects_the_power_switch() +{ + controller->setPowerEnabled(false); + controller->showLive(now); + run(12, 0, 5000); + TEST_ASSERT_FALSE(power.is_on); + TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live"))); +} + int main() { UNITY_BEGIN(); @@ -570,5 +847,31 @@ int main() RUN_TEST(test_short_wifi_drop_does_not_change_the_display); RUN_TEST(test_night_without_wifi_still_powers_off); + RUN_TEST(test_default_settings_are_valid); + RUN_TEST(test_settings_times_must_be_in_order); + RUN_TEST(test_settings_flip_time_and_port_ranges); + RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name); + RUN_TEST(test_time_parse_and_format); + RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format); + RUN_TEST(test_bitmap_hex_round_trip); + RUN_TEST(test_bitmap_hex_rejects_wrong_input); + RUN_TEST(test_bitmap_names); + RUN_TEST(test_schedule_disabled_is_always_on); + RUN_TEST(test_schedule_custom_times); + RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night); + RUN_TEST(test_evening_can_be_disabled); + RUN_TEST(test_bitmap_page_is_shown_once); + RUN_TEST(test_bitmap_page_redrawn_when_changed); + RUN_TEST(test_switching_back_to_the_clock_page); + RUN_TEST(test_bitmap_page_follows_the_schedule); + RUN_TEST(test_evening_clock_redrawn_when_the_evening_starts); + RUN_TEST(test_settings_change_redraws); + RUN_TEST(test_live_is_shown_right_away_even_at_night); + RUN_TEST(test_live_works_before_the_time_is_synced); + RUN_TEST(test_each_live_image_is_drawn); + RUN_TEST(test_stop_live_returns_to_the_page); + RUN_TEST(test_live_ends_after_timeout); + RUN_TEST(test_live_respects_the_power_switch); + return UNITY_END(); }