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 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 01:52:34 +02:00
1 parent c9e0800e36
commit 2b1ac12ed7
34 files changed
+1989 -117

No files matched your search

+15
View File
@@ -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/<name>.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 `<script>` part and run
`node --check` on it (node is installed).
## Git
- Remote: https://mangavalk.com/mangavalk/Flipdot-display.git, branch `main`.
+31 -2
View File
@@ -23,7 +23,8 @@ too, and it boots again each time the relay switches on. Only the ESP32 runs all
| Part | Folder | What it does |
|------|--------|--------------|
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps, exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps or your own bitmaps, has a [web UI](#web-ui), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
Because the driver is an `Adafruit_GFX` subclass on both boards, the ESP32 can use the
@@ -38,6 +39,7 @@ lib/BROSE9323/ shared flipdot driver (used by both targets)
src/nano/ Arduino Nano firmware
src/esp32/ ESP32 clock firmware
secrets.example.h template for WiFi / MQTT credentials
web/ the web UI
src/recovery/ ESP32 recovery app: WiFi setup page + OTA
partitions.csv ESP32 flash layout (recovery app + two clock firmware slots)
test/test_nano/ unit tests for the Nano firmware (run on the PC)
@@ -85,6 +87,31 @@ In VS Code you can also pick the environment in the PlatformIO status bar.
> also uses. Disconnect that wire before you flash the Nano over USB, or the upload may
> fail. Flashing over WiFi (`nano_ota`) doesn't have this problem.
## Web UI
Open `http://<ESP32 IP>/` (e.g. <http://10.1.1.198/>) in a browser. At the top is a live
view of the display: it shows what the dots show right now, updated every 1.5 s.
| Tab | What you can do |
|-----|-----------------|
| **Show** | Choose what the display shows during the day: the clock (Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. Two built-in ones (Nintendo) can't be deleted. |
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; yawning Pikachu and its time; the "No WiFi" screen; the flip time; the status LED. |
| **Home Assistant** | On/off, broker, port, user, password. Saving restarts the ESP32. An empty broker uses `secrets.h`. |
| **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. |
**Live mode** shows the image at once, also at night or before the time is known. It
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
switch still wins: when it is off, nothing is shown.
**Bitmaps** are stored on the ESP32 (LittleFS, 320 KB, room for well over a thousand). They
need the new flash layout from the [one-time installation](#one-time-installation-usb);
before that, only the built-in ones are available.
Settings are stored in the ESP32's NVS. The defaults are the original behaviour. There is
no login: anyone on your WiFi can use the page, as with OTA.
## Flashing the Nano over WiFi
The ESP32 can flash the Nano for you, so you don't need a USB cable at the display:
@@ -167,7 +194,7 @@ is broken. It shows what is going on as a short message on the display, and the
LED double-blinks.
```
flash (4 MB): bootloader | nvs | otadata | factory: recovery app | ota_0: clock | ota_1: clock
flash (4 MB): bootloader | nvs | otadata | factory: recovery | ota_0: clock | ota_1: clock | bitmaps
```
**When it starts**
@@ -282,6 +309,8 @@ The serial protocol is described in [src/nano/README.md](src/nano/README.md).
[One-time installation](#one-time-installation-usb)). Until then the ESP32 runs the old
flash layout: the clock firmware works, but without the recovery app; the Home
Assistant sensor shows "no recovery app".
- [ ] **Test the web UI on the real hardware**: live view, uploading and drawing a bitmap,
live mode, choosing a page, settings, Home Assistant settings (restart).
- [ ] **Test the recovery app on the real hardware**: the Home Assistant button, the setup
WiFi with a wrong WiFi password, 3 quick power cycles, and an OTA upload into it.
+28 -2
View File
@@ -70,6 +70,8 @@ void BROSE9323::display(bool force)
#ifdef ESPSERIAL
stream->print("D\n");
stream->flush();
// Shadow copy of what the dots show now (for the web UI)
memcpy(_old_buffer, _new_buffer, _buffer_size);
#else
if (_direct_mode)
return;
@@ -113,7 +115,20 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
#ifdef ESPSERIAL
if (y >= height() || y < 0 || x >= width() || x < 0)
return;
// Shadow copy for the web UI; S (direct mode) is visible right away
if (color)
_new_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
else
_new_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
if (_direct_mode)
{
if (color)
_old_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
else
_old_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
}
char s[10];
sprintf(s, "%c%02x%02x%d\n", _direct_mode ? 'S' : 's', x, y, (bool)color);
stream->print(s);
@@ -162,6 +177,7 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
void BROSE9323::fillScreen(uint16_t color)
{
#ifdef ESPSERIAL
memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size);
stream->print(color ? "F1\n" : "F0\n");
#else
memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size);
@@ -309,4 +325,14 @@ size_t BROSE9323::print(const char *ifsh)
ifsh = temp.c_str();
return Adafruit_GFX::print(ifsh);
}
}
bool BROSE9323::shownPixel(int16_t x, int16_t y) const
{
if (y >= height() || y < 0 || x >= width() || x < 0)
return false;
#if defined(__AVR_ATmega168P__) || defined(__AVR_ATmega168PB__) || defined(__AVR_ATmega168__)
return _new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
#else
return _old_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
#endif
}
+2
View File
@@ -87,5 +87,7 @@ public:
void printBuffer(void);
#endif
size_t print(const char *ifsh);
// State of a dot as of the last display() call (ESP32: shadow copy for the web UI)
bool shownPixel(int16_t x, int16_t y) const;
};
#endif //BROSE9323_H
+4 -3
View File
@@ -2,11 +2,12 @@
#
# factory recovery app (src/recovery): WiFi setup portal + OTA, never changed by OTA
# ota_0/1 clock firmware (src/esp32), updated over OTA in turns, with rollback
# spiffs LittleFS for the bitmaps uploaded in the web UI
#
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000
otadata, data, ota, 0xe000, 0x2000
factory, app, factory, 0x10000, 0x100000
ota_0, app, ota_0, 0x110000, 0x170000
ota_1, app, ota_1, 0x280000, 0x170000
coredump, data, coredump, 0x3F0000, 0x10000
ota_0, app, ota_0, 0x110000, 0x150000
ota_1, app, ota_1, 0x260000, 0x150000
spiffs, data, spiffs, 0x3B0000, 0x50000
Can't render this file because it contains an unexpected character in line 1 and column 44.
+1
View File
@@ -72,6 +72,7 @@ build_flags = -D ESPSERIAL -I src/esp32
lib_deps =
${env.lib_deps}
dawidchyrzynski/home-assistant-integration@^2.1.0
bblanchon/ArduinoJson@^7.0.0
knolleary/PubSubClient@^2.8.0
upload_protocol = espota
upload_port = 10.1.1.198
+25 -9
View File
@@ -11,12 +11,15 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| File | Contents |
|------|----------|
| `main.cpp` | Creates the objects, `setup()` and `loop()` |
| `config.h` | All settings: pins, timings, timezone, Home Assistant device id |
| `main.cpp` | `setup()` and `loop()`: the order of things at boot and in the loop |
| `app.*` | The objects (display, Nano link, controller, ...) and the actions the web UI uses: settings, page, live mode |
| `config.h` | Fixed settings: pins, timings, timezone, Home Assistant device id |
| `credentials.h` | Includes `secrets.h` (WiFi and MQTT credentials, **gitignored**); template: `secrets.example.h` |
| **`logic/`** | **Pure logic without Arduino code, tested on the PC** |
| `logic/display_controller.*` | Decides what is shown and when the display is powered (`DisplayController`) |
| `logic/schedule.*` | When the clock is shown (night, clock, standby) |
| `logic/display_controller.*` | Decides what is shown and when the display is powered: page, live mode, schedule, Home Assistant switches (`DisplayController`) |
| `logic/settings.*` | The settings of the web UI with their defaults and checks |
| `logic/schedule.*` | When the display is on (night, clock, standby), from the settings |
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, bitmap names |
| `logic/power_timer.*` | Countdown to switching the display off |
| `logic/status_led.h` | Blink pattern of the built-in LED |
| `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode |
@@ -24,8 +27,10 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `system/firmware_guard.*` | Rollback of bad OTA firmware, watchdog, crash counter, power-cycle trigger, status for Home Assistant |
| `system/boot_target.*` | Switching between the clock firmware and the recovery app |
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| `system/settings_store.*` | The web UI settings in NVS |
| **`display/`** | **What is drawn** |
| `display/frames.*` | The screens of the clock: clock, "No WiFi", logo, blank (`FlipdotFrames`) |
| `display/frames.*` | The screens: clock, "No WiFi", page bitmap, live image, logo, blank (`FlipdotFrames`) |
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
| `display/status_screen.*` | Two-line status messages at boot, after a crash and in the recovery app (`StatusScreen`) |
| `display/text.*` | Centered text lines on the 84 x 19 display |
| `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) |
@@ -35,6 +40,9 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| **`net/`** | **Network** |
| `net/wifi_ota.*` | WiFi (with a restart when it stays down), ESP32 OTA updates, OTA-only mode |
| `net/home_assistant.*` | MQTT and the Home Assistant entities (two switches, firmware sensor, recovery button) |
| **`web/`** | **Web UI** (see the [main README](../../README.md#web-ui)) |
| `web/web_ui.*` | Web server and JSON API (the endpoints are listed in `web_ui.h`) |
| `web/web_page.h` | The page: HTML and JavaScript; the browser converts images and draws the previews |
`DisplayController` only talks to two small interfaces, `DisplayPower` (implemented by
`NanoLink`) and `Frames` (implemented by `FlipdotFrames`). The tests replace both with
@@ -130,7 +138,15 @@ controller (power on/off, redraws, both Home Assistant switches).
## Adding bitmaps
Convert an image with [image2cpp](https://javl.github.io/image2cpp/) to "Arduino code",
horizontal, 1 bit per pixel. Paste the array into `display/bitmaps.h` and draw it in
`display/frames.cpp` with `_display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is
84 × 19 pixels.
The easy way is the web UI (Draw / Live tab): load an image, adjust it, save it.
To build a bitmap into the firmware instead: convert it with
[image2cpp](https://javl.github.io/image2cpp/) to "Arduino code", horizontal, 1 bit per
pixel, 84 x 19. Paste the array into `display/bitmaps.h` and add it to `BUILTIN` in
`display/bitmap_store.cpp`.
## Live view of the display
The BROSE9323 driver keeps a shadow copy of what was sent to the Nano (`shownPixel()`),
updated on every `display()`. `GET /api/screen` returns it, and the web UI polls it. It
also keeps the last image while the display is switched off, like the dots do.
+122
View File
@@ -0,0 +1,122 @@
#include "app.h"
#include <string.h>
#include "config.h"
#include "display/bitmap_store.h"
#include "system/settings_store.h"
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
FlipdotFrames frames(display);
StatusScreen status(display, nano);
DisplayController controller(nano, frames);
static Settings settings;
static bool restart_pending = false;
static uint32_t restart_at = 0;
// Load the page bitmap into the frames; falls back to the clock when it is gone
static void applyPage()
{
if (strcmp(settings.page, PAGE_CLOCK) != 0)
{
uint8_t bitmap[BITMAP_BYTES];
if (loadBitmap(settings.page, bitmap))
{
frames.setPageBitmap(bitmap);
controller.setPage(Page::Bitmap);
return;
}
Serial.printf("Page bitmap \"%s\" not found, showing the clock\n", settings.page);
}
controller.setPage(Page::Clock);
}
static void apply()
{
controller.setSettings(settings.schedule, settings.clock);
status.setFlipTime(settings.flip_time);
applyPage();
}
const Settings &appSettings()
{
return settings;
}
void appLoadSettings()
{
loadSettings(settings);
apply();
}
static bool sameHomeAssistant(const HomeAssistantSettings &a, const HomeAssistantSettings &b)
{
return a.enabled == b.enabled && a.port == b.port && strcmp(a.host, b.host) == 0 &&
strcmp(a.user, b.user) == 0 && strcmp(a.password, b.password) == 0;
}
const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
{
const char *error = validateSettings(new_settings);
if (error)
return error;
if (strcmp(new_settings.page, PAGE_CLOCK) != 0 && !bitmapExists(new_settings.page))
return "Bitmap not found";
restart_needed = !sameHomeAssistant(settings.home_assistant, new_settings.home_assistant);
bool flip_time_changed = settings.flip_time != new_settings.flip_time;
settings = new_settings;
saveSettings(settings);
apply();
if (flip_time_changed && nano.isOn())
display.setTiming(settings.flip_time);
return nullptr;
}
const char *appSelectPage(const char *name)
{
if (strcmp(name, PAGE_CLOCK) != 0 && !bitmapExists(name))
return "Bitmap not found";
strncpy(settings.page, name, sizeof(settings.page) - 1);
settings.page[sizeof(settings.page) - 1] = '\0';
saveSettings(settings);
applyPage();
return nullptr;
}
void appBitmapChanged(const char *name)
{
if (strcmp(settings.page, name) == 0)
{
applyPage();
controller.pageChanged();
}
}
void appShowLive(const uint8_t *bitmap)
{
frames.setLiveBitmap(bitmap);
controller.showLive(millis());
}
void appStopLive()
{
controller.stopLive();
}
void appRestartLater(uint32_t delay_ms)
{
restart_pending = true;
restart_at = millis() + delay_ms;
}
void appLoop()
{
if (restart_pending && (int32_t)(millis() - restart_at) >= 0)
{
Serial.println("Restarting for the new settings");
ESP.restart();
}
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// The objects of the clock firmware and the actions the web UI and Home Assistant use.
// main.cpp only decides the order of things at boot and in loop().
#include "BROSE9323.h"
#include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h"
#include "logic/settings.h"
#include "nano_link/nano_link.h"
extern NanoLink nano;
extern BROSE9323 display;
extern FlipdotFrames frames;
extern StatusScreen status;
extern DisplayController controller;
// Settings: loaded at boot, changed in the web UI
const Settings &appSettings();
void appLoadSettings();
// Validates, stores and applies. Returns nullptr or an error message. Sets
// restart_needed when the change only works after a restart (Home Assistant).
const char *appSaveSettings(const Settings &settings, bool &restart_needed);
// The page shown during the day: PAGE_CLOCK or a bitmap name (stored in the settings)
const char *appSelectPage(const char *name);
// A bitmap was saved or deleted: redraw when it is the current page
void appBitmapChanged(const char *name);
// Live mode of the web UI
void appShowLive(const uint8_t *bitmap);
void appStopLive();
// Restart the ESP32 after `delay_ms` (so a web response can still be sent)
void appRestartLater(uint32_t delay_ms);
void appLoop();
+2
View File
@@ -41,6 +41,8 @@ const uint32_t CLEAR_BEFORE_OFF_TIME = 1000;
const uint32_t STATUS_FLIP_TIME = 2000;
// WiFi down this long while the clock runs: show "No WiFi" with the time instead
const uint32_t NO_WIFI_SHOW_TIME = 60000;
// Live mode of the web UI ends this long after the last image was sent
const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000;
// Power off this long after the display is no longer needed (night, standby, logo)
const uint32_t POWER_OFF_DELAY = 30000;
+131
View File
@@ -0,0 +1,131 @@
#include "display/bitmap_store.h"
#include <LittleFS.h>
#include <strings.h>
#include "display/bitmaps.h"
static const char *DIR = "/bitmaps";
struct BuiltinBitmap
{
const char *name;
const uint8_t *data; // PROGMEM, BITMAP_BYTES
};
static const BuiltinBitmap BUILTIN[] = {
{"Nintendo", nintendo_bitmap_Nintendo_new},
{"Nintendo border", nintendo_bitmap_nintendo_border},
};
static bool mounted = false;
static const BuiltinBitmap *builtin(const char *name)
{
for (const BuiltinBitmap &b : BUILTIN)
if (strcasecmp(b.name, name) == 0)
return &b;
return nullptr;
}
static String path(const char *name)
{
return String(DIR) + "/" + name + ".bin";
}
bool bitmapStoreBegin()
{
mounted = LittleFS.begin(true);
if (mounted && !LittleFS.exists(DIR))
LittleFS.mkdir(DIR);
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
return mounted;
}
bool bitmapStorageAvailable()
{
return mounted;
}
void bitmapStorageUsage(size_t &used, size_t &total)
{
used = mounted ? LittleFS.usedBytes() : 0;
total = mounted ? LittleFS.totalBytes() : 0;
}
std::vector<BitmapInfo> listBitmaps()
{
std::vector<BitmapInfo> list;
for (const BuiltinBitmap &b : BUILTIN)
list.push_back({b.name, true});
if (!mounted)
return list;
File dir = LittleFS.open(DIR);
for (File file = dir.openNextFile(); file; file = dir.openNextFile())
{
String name = file.name();
int slash = name.lastIndexOf('/');
if (slash >= 0)
name = name.substring(slash + 1);
if (!name.endsWith(".bin"))
continue;
name.remove(name.length() - 4);
if (validBitmapName(name.c_str()) && !builtin(name.c_str()))
list.push_back({name, false});
}
return list;
}
bool bitmapExists(const char *name)
{
if (builtin(name))
return true;
return mounted && validBitmapName(name) && LittleFS.exists(path(name));
}
bool loadBitmap(const char *name, uint8_t *bitmap)
{
const BuiltinBitmap *b = builtin(name);
if (b)
{
memcpy_P(bitmap, b->data, BITMAP_BYTES);
return true;
}
if (!mounted || !validBitmapName(name))
return false;
File file = LittleFS.open(path(name), "r");
if (!file)
return false;
bool ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
return ok;
}
const char *saveBitmap(const char *name, const uint8_t *bitmap)
{
if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (builtin(name))
return "A built-in bitmap has this name";
if (!mounted)
return "No bitmap storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && listBitmaps().size() >= (size_t)MAX_BITMAPS + (sizeof(BUILTIN) / sizeof(BUILTIN[0])))
return "Too many bitmaps, delete some first";
File file = LittleFS.open(path(name), "w");
if (!file)
return "Could not write the file";
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
return ok ? nullptr : "Storage full";
}
const char *deleteBitmap(const char *name)
{
if (builtin(name))
return "Built-in bitmaps can't be deleted";
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Bitmap not found";
return LittleFS.remove(path(name)) ? nullptr : "Could not delete the file";
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "logic/bitmap_format.h"
// Full screen bitmaps (84 x 19, see logic/bitmap_format.h): built-in ones from
// display/bitmaps.h and the ones uploaded in the web UI, stored on LittleFS
// (partition "spiffs" in partitions.csv) as /bitmaps/<name>.bin.
struct BitmapInfo
{
String name;
bool builtin;
};
const int MAX_BITMAPS = 200;
// Mounts LittleFS (formats it the first time). False without the partition (old flash
// layout): only the built-in bitmaps are available then.
bool bitmapStoreBegin();
bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
std::vector<BitmapInfo> listBitmaps();
bool bitmapExists(const char *name);
bool loadBitmap(const char *name, uint8_t *bitmap);
// Return nullptr on success, otherwise a message for the user
const char *saveBitmap(const char *name, const uint8_t *bitmap);
const char *deleteBitmap(const char *name);
+34 -6
View File
@@ -1,10 +1,11 @@
#include "display/frames.h"
#include <stdio.h>
#include <string.h>
#include "display/bitmap_store.h"
#include "display/bitmaps.h"
#include "display/text.h"
#include "logic/schedule.h"
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
static const int CLOCK_X = 3;
@@ -13,13 +14,13 @@ FlipdotFrames::FlipdotFrames(BROSE9323 &display) : _display(display)
{
}
void FlipdotFrames::clock(int hour, int minute)
void FlipdotFrames::clock(int hour, int minute, bool evening)
{
char time[6];
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
_display.fillScreen(0);
if (isEvening(hour))
if (evening)
_display.drawBitmap(CLOCK_X, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
else
_display.drawBitmap(CLOCK_X, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
@@ -40,11 +41,21 @@ void FlipdotFrames::noWifi(int hour, int minute)
_display.display();
}
void FlipdotFrames::page()
{
_drawFullScreen(_page);
}
void FlipdotFrames::live()
{
_drawFullScreen(_live);
}
void FlipdotFrames::logo()
{
_display.fillScreen(0);
_display.drawBitmap(0, 0, nintendo_bitmap_Nintendo_new, 84, 19, 1);
_display.display();
uint8_t logo[BITMAP_BYTES];
if (loadBitmap("Nintendo", logo))
_drawFullScreen(logo);
}
void FlipdotFrames::blank()
@@ -52,3 +63,20 @@ void FlipdotFrames::blank()
_display.fillScreen(0);
_display.display();
}
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
{
memcpy(_page, bitmap, BITMAP_BYTES);
}
void FlipdotFrames::setLiveBitmap(const uint8_t *bitmap)
{
memcpy(_live, bitmap, BITMAP_BYTES);
}
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap)
{
_display.fillScreen(0);
_display.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1);
_display.display();
}
+13 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "BROSE9323.h"
#include "logic/bitmap_format.h"
#include "logic/display_controller.h"
// Draws the screens on the flipdot display. Each method sends one complete frame.
@@ -10,13 +11,24 @@ public:
explicit FlipdotFrames(BROSE9323 &display);
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute) override;
void clock(int hour, int minute, bool evening) override;
// "No WiFi" with the time below it
void noWifi(int hour, int minute) override;
// The bitmap set with setPageBitmap()
void page() override;
// The image set with setLiveBitmap()
void live() override;
// Nintendo logo
void logo() override;
void blank() override;
void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap);
private:
BROSE9323 &_display;
uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {};
void _drawFullScreen(const uint8_t *bitmap);
};
+7 -2
View File
@@ -5,10 +5,15 @@
#include "config.h"
#include "display/text.h"
StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(display), _nano(nano)
StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(display), _nano(nano), _flip_time(FLIP_TIME)
{
}
void StatusScreen::setFlipTime(uint16_t flip_time)
{
_flip_time = flip_time;
}
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
{
Serial.printf("Display: %s / %s\n", line1, line2 ? line2 : "");
@@ -16,7 +21,7 @@ void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
if (!_started)
{
_display.begin(&_nano.stream());
_display.setTiming(FLIP_TIME);
_display.setTiming(_flip_time);
_started = true;
}
drawTextLines(_display, line1, line2);
+4
View File
@@ -14,8 +14,12 @@ public:
// power off again once the dots have flipped: flipdots keep their image without power.
void show(const char *line1, const char *line2, bool keep_on = false);
// Flip time sent to the Nano with the first message (default FLIP_TIME)
void setFlipTime(uint16_t flip_time);
private:
BROSE9323 &_display;
NanoLink &_nano;
bool _started = false;
uint16_t _flip_time;
};
+81
View File
@@ -0,0 +1,81 @@
#include "logic/bitmap_format.h"
#include <ctype.h>
#include <string.h>
bool bitmapPixel(const uint8_t *bitmap, int x, int y)
{
if (x < 0 || y < 0 || x >= BITMAP_WIDTH || y >= BITMAP_HEIGHT)
return false;
return bitmap[y * BITMAP_ROW_BYTES + x / 8] & (0x80 >> (x & 7));
}
void setBitmapPixel(uint8_t *bitmap, int x, int y, bool on)
{
if (x < 0 || y < 0 || x >= BITMAP_WIDTH || y >= BITMAP_HEIGHT)
return;
uint8_t &byte = bitmap[y * BITMAP_ROW_BYTES + x / 8];
if (on)
byte |= 0x80 >> (x & 7);
else
byte &= ~(0x80 >> (x & 7));
}
static int hexValue(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
bool bitmapFromHex(const char *hex, uint8_t *bitmap)
{
if (hex == nullptr || strlen(hex) != (size_t)BITMAP_HEX_LENGTH)
return false;
for (int i = 0; i < BITMAP_BYTES; i++)
{
int high = hexValue(hex[2 * i]);
int low = hexValue(hex[2 * i + 1]);
if (high < 0 || low < 0)
return false;
bitmap[i] = (high << 4) | low;
}
return true;
}
void bitmapToHex(const uint8_t *bitmap, char *out)
{
static const char digits[] = "0123456789abcdef";
for (int i = 0; i < BITMAP_BYTES; i++)
{
out[2 * i] = digits[bitmap[i] >> 4];
out[2 * i + 1] = digits[bitmap[i] & 0x0F];
}
out[BITMAP_HEX_LENGTH] = '\0';
}
bool validBitmapName(const char *name)
{
if (name == nullptr)
return false;
size_t len = strlen(name);
if (len == 0 || len > (size_t)BITMAP_NAME_MAX)
return false;
if (name[0] == ' ' || name[len - 1] == ' ')
return false;
for (size_t i = 0; i < len; i++)
{
char c = name[i];
if (!isalnum((unsigned char)c) && c != ' ' && c != '-' && c != '_')
return false;
}
// "clock" is the clock page
char lower[BITMAP_NAME_MAX + 1];
for (size_t i = 0; i <= len; i++)
lower[i] = tolower((unsigned char)name[i]);
return strcmp(lower, "clock") != 0;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <stdint.h>
// Full screen bitmaps (84 x 19), stored and sent to the web UI in the format of
// Adafruit_GFX::drawBitmap: rows from top to bottom, 11 bytes per row, the most
// significant bit is the leftmost dot. Pure logic, tested in test/test_esp32.
const int BITMAP_WIDTH = 84;
const int BITMAP_HEIGHT = 19;
const int BITMAP_ROW_BYTES = (BITMAP_WIDTH + 7) / 8;
const int BITMAP_BYTES = BITMAP_ROW_BYTES * BITMAP_HEIGHT; // 209
const int BITMAP_HEX_LENGTH = BITMAP_BYTES * 2; // 418
bool bitmapPixel(const uint8_t *bitmap, int x, int y);
void setBitmapPixel(uint8_t *bitmap, int x, int y, bool on);
// Exactly BITMAP_HEX_LENGTH hex digits. Returns false on anything else.
bool bitmapFromHex(const char *hex, uint8_t *bitmap);
// out must hold BITMAP_HEX_LENGTH + 1 characters
void bitmapToHex(const uint8_t *bitmap, char *out);
// Bitmap names: 1-24 characters, letters, digits, space, '-' and '_', no space at the
// start or end, not "clock" (that is the clock page)
const int BITMAP_NAME_MAX = 24;
bool validBitmapName(const char *name);
+93 -26
View File
@@ -7,6 +7,42 @@ DisplayController::DisplayController(DisplayPower &power, Frames &frames) : _pow
{
}
void DisplayController::setSettings(const ScheduleSettings &schedule, const ClockSettings &clock)
{
_schedule = schedule;
_clock = clock;
_content_revision++; // e.g. evening time changed: redraw
}
void DisplayController::setPage(Page page)
{
_page = page;
_content_revision++;
}
void DisplayController::pageChanged()
{
_content_revision++;
}
void DisplayController::showLive(uint32_t now)
{
_live = true;
_live_since = now;
_content_revision++;
}
void DisplayController::stopLive()
{
_live = false;
_content_revision++;
}
bool DisplayController::liveActive() const
{
return _live;
}
void DisplayController::setClockEnabled(bool enabled)
{
_clock_enabled = enabled;
@@ -36,7 +72,10 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
_wifi_lost = true;
_wifi_lost_since = now;
}
bool show_no_wifi = _wifi_lost && now - _wifi_lost_since >= NO_WIFI_SHOW_TIME;
bool show_no_wifi = _clock.no_wifi_screen && _wifi_lost && now - _wifi_lost_since >= NO_WIFI_SHOW_TIME;
if (_live && now - _live_since >= LIVE_TIMEOUT)
stopLive();
// Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame
if (!_power.isOn())
@@ -53,12 +92,21 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
{
if (_power.isOn())
{
_showBlank();
_showOnce(Screen::Blank);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME);
}
return;
}
// Live mode from the web UI: shown right away, whatever the time
if (_live)
{
_off_timer.cancel();
_ensureOn();
_showOnce(Screen::Live);
return;
}
// Keep the boot screen until the time is known
if (!time_valid)
return;
@@ -68,64 +116,83 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
// The logo is only shown while the display is on; it never switches the power on
if (_power.isOn())
{
_showLogo();
_showOnce(Screen::Logo);
_off_timer.request(now, POWER_OFF_DELAY);
}
return;
}
switch (scheduleFor(hour, minute))
switch (scheduleFor(hour, minute, _schedule))
{
case ScheduleMode::Night:
if (_power.isOn())
{
_showBlank();
_showOnce(Screen::Blank);
_off_timer.request(now, POWER_OFF_DELAY);
}
break;
case ScheduleMode::Clock:
_off_timer.cancel();
if (!_power.isOn())
{
_power.on();
_screen = Screen::Unknown;
}
_showClock(hour, minute, show_no_wifi);
_ensureOn();
if (_page == Page::Clock)
_showClock(hour, minute, show_no_wifi);
else
_showOnce(Screen::Page);
break;
case ScheduleMode::Standby:
// Flipdots keep their image without power, so the last time stays visible
// Flipdots keep their image without power, so the last image stays visible
if (_power.isOn())
_off_timer.request(now, POWER_OFF_DELAY);
break;
}
}
void DisplayController::_ensureOn()
{
if (_power.isOn())
return;
_power.on();
_screen = Screen::Unknown;
}
void DisplayController::_showClock(int hour, int minute, bool no_wifi)
{
Screen screen = no_wifi ? Screen::NoWifi : Screen::Clock;
if (_screen == screen && _shown_hour == hour && _shown_minute == minute)
bool evening = isEvening(hour, minute, _clock);
if (_screen == screen && _shown_hour == hour && _shown_minute == minute && _shown_evening == evening &&
_shown_revision == _content_revision)
return;
if (no_wifi)
_frames.noWifi(hour, minute);
else
_frames.clock(hour, minute);
_frames.clock(hour, minute, evening);
_screen = screen;
_shown_hour = hour;
_shown_minute = minute;
_shown_evening = evening;
_shown_revision = _content_revision;
}
void DisplayController::_showLogo()
// Screens without the time: only sent again when they or their content changed
void DisplayController::_showOnce(Screen screen)
{
if (_screen == Screen::Logo)
if (_screen == screen && _shown_revision == _content_revision)
return;
_frames.logo();
_screen = Screen::Logo;
}
void DisplayController::_showBlank()
{
if (_screen == Screen::Blank)
return;
_frames.blank();
_screen = Screen::Blank;
switch (screen)
{
case Screen::Page:
_frames.page();
break;
case Screen::Live:
_frames.live();
break;
case Screen::Logo:
_frames.logo();
break;
default:
_frames.blank();
break;
}
_screen = screen;
_shown_revision = _content_revision;
}
+42 -7
View File
@@ -3,6 +3,7 @@
#include <stdint.h>
#include "logic/power_timer.h"
#include "logic/settings.h"
// Power for the panels and the Nano (implemented by NanoLink)
class DisplayPower
@@ -18,22 +19,44 @@ public:
class Frames
{
public:
virtual void clock(int hour, int minute) = 0;
virtual void clock(int hour, int minute, bool evening) = 0;
// Shown instead of the clock while WiFi is down (the time keeps running without it)
virtual void noWifi(int hour, int minute) = 0;
// The bitmap selected as page
virtual void page() = 0;
// The image sent from the web UI's live mode
virtual void live() = 0;
virtual void logo() = 0;
virtual void blank() = 0;
};
// Decides what the display shows and when it is powered, based on the time and the two
// Home Assistant switches. A frame is only sent when it changes. Pure logic, tested in
// test/test_esp32.
enum class Page : uint8_t
{
Clock, // Pikachu with the time
Bitmap, // a bitmap (FlipdotFrames holds which one)
};
// Decides what the display shows and when it is powered, based on the time, the settings,
// the Home Assistant switches and the web UI. A frame is only sent when it changes. Pure
// logic, tested in test/test_esp32.
class DisplayController
{
public:
DisplayController(DisplayPower &power, Frames &frames);
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the clock
void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock);
// What is shown during the day. Call pageChanged() when the bitmap itself changed.
void setPage(Page page);
void pageChanged();
// Live mode: show Frames::live() now, whatever the time, until stopLive() or
// LIVE_TIMEOUT after the last showLive()
void showLive(uint32_t now);
void stopLive();
bool liveActive() const;
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the page
void setClockEnabled(bool enabled);
// Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
void setPowerEnabled(bool enabled);
@@ -51,6 +74,8 @@ private:
Unknown, // nothing sent since the last power on
Clock,
NoWifi,
Page,
Live,
Logo,
Blank,
};
@@ -59,17 +84,27 @@ private:
Frames &_frames;
PowerTimer _off_timer;
ScheduleSettings _schedule;
ClockSettings _clock;
Page _page = Page::Clock;
bool _clock_enabled = true;
bool _power_enabled = true;
Screen _screen = Screen::Unknown;
int _shown_hour = -1;
int _shown_minute = -1;
bool _shown_evening = false;
uint32_t _content_revision = 0; // changed page, bitmap or live image
uint32_t _shown_revision = 0;
bool _live = false;
uint32_t _live_since = 0;
bool _wifi_lost = false;
uint32_t _wifi_lost_since = 0;
void _ensureOn();
void _showClock(int hour, int minute, bool no_wifi);
void _showLogo();
void _showBlank();
void _showOnce(Screen screen);
};
+9 -11
View File
@@ -1,21 +1,19 @@
#include "logic/schedule.h"
static const int DAY_START_HOUR = 8;
static const int LATE_START_HOUR = 22;
static const int NIGHT_START_HOUR = 23;
static const int NIGHT_START_MINUTE = 31;
static const int EVENING_START_HOUR = 17;
ScheduleMode scheduleFor(int hour, int minute)
ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &s)
{
if (hour < DAY_START_HOUR || (hour >= NIGHT_START_HOUR && minute >= NIGHT_START_MINUTE))
if (!s.enabled)
return ScheduleMode::Clock;
int now = hour * 60 + minute;
if (now < s.day_start || now >= s.night_start)
return ScheduleMode::Night;
if (hour < LATE_START_HOUR || minute % 15 == 0)
if (now < s.late_start || !s.quarter_hours || minute % 15 == 0)
return ScheduleMode::Clock;
return ScheduleMode::Standby;
}
bool isEvening(int hour)
bool isEvening(int hour, int minute, const ClockSettings &s)
{
return hour >= EVENING_START_HOUR;
return s.evening_enabled && hour * 60 + minute >= s.evening_start;
}
+8 -5
View File
@@ -1,11 +1,14 @@
#pragma once
// When the clock is shown. Pure logic, tested in test/test_esp32.
#include "logic/settings.h"
// When the display is on. Pure logic, tested in test/test_esp32. With the default
// settings:
//
// 00:00 - 07:59 Night screen cleared, power off
// 08:00 - 21:59 Clock clock updated every minute
// 08:00 - 21:59 Clock page shown (the clock is updated every minute)
// 22:00 - 23:30 Clock only during the minutes :00, :15, :30 and :45
// Standby in between: power off, the last time stays on the dots
// Standby in between: power off, the last image stays on the dots
// 23:31 - 23:59 Night
enum class ScheduleMode
@@ -15,7 +18,7 @@ enum class ScheduleMode
Standby,
};
ScheduleMode scheduleFor(int hour, int minute);
ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &settings);
// Evening: the yawning Pikachu is shown
bool isEvening(int hour);
bool isEvening(int hour, int minute, const ClockSettings &settings);
+58
View File
@@ -0,0 +1,58 @@
#include "logic/settings.h"
#include <stdio.h>
#include <string.h>
#include "logic/bitmap_format.h"
static bool terminated(const char *text, int size)
{
return memchr(text, '\0', size) != nullptr;
}
const char *validateSettings(const Settings &s)
{
if (!terminated(s.page, sizeof(s.page)) ||
(strcmp(s.page, PAGE_CLOCK) != 0 && !validBitmapName(s.page)))
return "Unknown page";
const ScheduleSettings &t = s.schedule;
if (t.day_start >= MINUTES_PER_DAY || t.late_start > MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY)
return "Times must be between 00:00 and 24:00";
if (t.day_start > t.late_start || t.late_start > t.night_start)
return "The times must be in order: day start, late evening, night";
if (s.clock.evening_start >= MINUTES_PER_DAY)
return "The evening must start before 24:00";
if (s.flip_time < SETTINGS_FLIP_TIME_MIN || s.flip_time > SETTINGS_FLIP_TIME_MAX)
return "The flip time must be between 50 and 2000 us";
const HomeAssistantSettings &ha = s.home_assistant;
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
!terminated(ha.password, sizeof(ha.password)))
return "Home Assistant text too long";
if (ha.port == 0)
return "Invalid MQTT port";
return nullptr;
}
bool parseTime(const char *text, uint16_t &minutes)
{
if (text == nullptr)
return false;
int hour = 0, minute = 0;
char extra;
if (sscanf(text, "%d:%d%c", &hour, &minute, &extra) != 2)
return false;
if (hour < 0 || minute < 0 || minute > 59 || hour * 60 + minute > MINUTES_PER_DAY)
return false;
minutes = hour * 60 + minute;
return true;
}
void formatTime(uint16_t minutes, char *out)
{
snprintf(out, 6, "%02u:%02u", (unsigned)(minutes / 60) % 100, (unsigned)(minutes % 60));
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <stdint.h>
// Everything that can be changed in the web UI. Stored in NVS (system/settings_store),
// the defaults are the original behaviour. Pure logic, tested in test/test_esp32.
//
// Times are minutes since midnight (8:00 = 480).
struct ScheduleSettings
{
bool enabled = true; // false: the display is always on
uint16_t day_start = 8 * 60; // display on from
uint16_t late_start = 22 * 60; // from here only at the quarter hours (see quarter_hours)
bool quarter_hours = true; // false: on all the time until night_start
uint16_t night_start = 23 * 60 + 31; // display off from (up to 24:00 = never)
};
struct ClockSettings
{
bool evening_enabled = true; // yawning Pikachu in the evening
uint16_t evening_start = 17 * 60;
bool no_wifi_screen = true; // "No WiFi" instead of the clock when WiFi is down
};
struct HomeAssistantSettings
{
bool enabled = true;
char host[64] = ""; // empty: BROKER_ADDR from secrets.h
uint16_t port = 1883;
char user[32] = "";
char password[64] = "";
};
// What is shown during the day: "clock" or the name of a bitmap
const int PAGE_NAME_SIZE = 25;
#define PAGE_CLOCK "clock"
struct Settings
{
char page[PAGE_NAME_SIZE] = PAGE_CLOCK;
ScheduleSettings schedule;
ClockSettings clock;
HomeAssistantSettings home_assistant;
uint16_t flip_time = 225; // flip pulse length in us, sent to the Nano
bool status_led = true;
};
const uint16_t MINUTES_PER_DAY = 24 * 60;
const uint16_t SETTINGS_FLIP_TIME_MIN = 50;
const uint16_t SETTINGS_FLIP_TIME_MAX = 2000;
// Checks the values. Returns nullptr when they are fine, otherwise a message for the user.
const char *validateSettings(const Settings &settings);
// "HH:MM" (00:00 - 24:00) <-> minutes since midnight
bool parseTime(const char *text, uint16_t &minutes);
// out must hold 6 characters
void formatTime(uint16_t minutes, char *out);
+15 -15
View File
@@ -1,27 +1,20 @@
// ESP32 firmware: decides what the flipdot display shows (clock, Home Assistant switches)
// and sends it to the Nano, which drives the panels. See README.md.
// ESP32 firmware: decides what the flipdot display shows (clock, bitmaps, web UI, Home
// Assistant) and sends it to the Nano, which drives the panels. See README.md.
// The objects and the actions on them are in app.cpp; this file only sets the order.
#include <Arduino.h>
#include <time.h>
#include "BROSE9323.h"
#include "app.h"
#include "config.h"
#include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h"
#include "display/bitmap_store.h"
#include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h"
#include "nano_link/nano_link.h"
#include "net/home_assistant.h"
#include "net/wifi_ota.h"
#include "system/boot_target.h"
#include "system/firmware_guard.h"
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
FlipdotFrames frames(display);
StatusScreen status(display, nano);
DisplayController controller(nano, frames);
#include "web/web_ui.h"
// Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
// The controller then powers it on again with the new firmware.
@@ -39,6 +32,9 @@ void setup()
// Decided first, before anything that could crash
BootMode boot_mode = firmwareGuardBegin();
bitmapStoreBegin();
appLoadSettings();
if (boot_mode == BootMode::Normal)
status.show("Connecting", "to WiFi...", true);
wifiBegin(); // starts the recovery app when it fails
@@ -66,7 +62,8 @@ void setup()
status.show("Getting", "the time...", true);
configTzTime(TIMEZONE, NTP_SERVER);
homeAssistantBegin(controller);
homeAssistantBegin(controller, appSettings().home_assistant);
webUiBegin();
firmwareGuardStartWatchdog();
}
@@ -80,6 +77,8 @@ void loop()
homeAssistantSetFirmwareStatus(firmwareGuardStatus());
homeAssistantLoop();
webUiLoop();
appLoop();
// While the Nano is being flashed, nothing else may talk to it
if (flashBridge.handle())
@@ -89,5 +88,6 @@ void loop()
bool time_valid = getLocalTime(&now, 0);
controller.update(millis(), time_valid, now.tm_hour, now.tm_min, wifiConnected());
digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn()));
bool led = appSettings().status_led && statusLedLevel(millis(), wifiConnected(), nano.isOn());
digitalWrite(LED_PIN, led);
}
+21 -2
View File
@@ -22,6 +22,7 @@ static char firmware_status[64] = "";
static bool firmware_status_sent = false;
static DisplayController *displayController = nullptr;
static bool enabled = false;
// The switches are not optimistic: the new state must be reported back to Home Assistant
static void onClockCommand(bool state, HASwitch *sender)
@@ -36,9 +37,15 @@ static void onPowerCommand(bool state, HASwitch *sender)
sender->setState(state);
}
void homeAssistantBegin(DisplayController &controller)
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
{
displayController = &controller;
enabled = settings.enabled;
if (!enabled)
{
Serial.println("Home Assistant disabled in the settings");
return;
}
device.setName(HA_DEVICE_NAME);
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
@@ -62,11 +69,23 @@ void homeAssistantBegin(DisplayController &controller)
// The sensor doesn't resend its value on reconnect by itself
mqtt.onConnected([]() { firmware_status_sent = false; });
mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASSWORD);
// settings must stay alive: the MQTT library keeps the pointers
if (settings.host[0])
mqtt.begin(settings.host, settings.port, settings.user[0] ? settings.user : nullptr,
settings.password[0] ? settings.password : nullptr);
else
mqtt.begin(BROKER_ADDR, settings.port, MQTT_USER, MQTT_PASSWORD); // secrets.h
}
bool homeAssistantConnected()
{
return enabled && mqtt.isConnected();
}
void homeAssistantLoop()
{
if (!enabled)
return;
mqtt.loop();
if (!firmware_status_sent && firmware_status[0] && mqtt.isConnected())
firmware_status_sent = firmwareSensor.setValue(firmware_status);
+6 -1
View File
@@ -1,14 +1,19 @@
#pragma once
#include "logic/display_controller.h"
#include "logic/settings.h"
// Home Assistant integration over MQTT (with discovery):
// FlipDot Display Clock switch: on = clock, off = Nintendo logo
// FlipDot Display Power switch: off = clear the display and switch it off
// FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..."
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
void homeAssistantBegin(DisplayController &controller);
//
// The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER
// and MQTT_PASSWORD from secrets.h. Changes need a restart.
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings);
void homeAssistantLoop();
bool homeAssistantConnected();
// Publish the firmware status (only sent when it changes, and again after a reconnect)
void homeAssistantSetFirmwareStatus(const char *status);
+40
View File
@@ -0,0 +1,40 @@
#include "system/settings_store.h"
#include <Preferences.h>
static const char *NAMESPACE = "flipdot";
static const char *KEY = "settings";
// Increase when the Settings struct changes; old records are then ignored (defaults)
static const uint16_t VERSION = 1;
struct StoredSettings
{
uint16_t version;
uint16_t size;
Settings settings;
};
void loadSettings(Settings &settings)
{
settings = Settings();
StoredSettings stored;
Preferences prefs;
prefs.begin(NAMESPACE, true);
size_t read = prefs.getBytes(KEY, &stored, sizeof(stored));
prefs.end();
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings) &&
validateSettings(stored.settings) == nullptr)
settings = stored.settings;
}
void saveSettings(const Settings &settings)
{
StoredSettings stored;
stored.version = VERSION;
stored.size = sizeof(Settings);
stored.settings = settings;
Preferences prefs;
prefs.begin(NAMESPACE, false);
prefs.putBytes(KEY, &stored, sizeof(stored));
prefs.end();
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "logic/settings.h"
// The web UI settings in NVS. A missing or outdated record gives the defaults.
void loadSettings(Settings &settings);
void saveSettings(const Settings &settings);
+188
View File
@@ -0,0 +1,188 @@
#pragma once
#include <pgmspace.h>
// The web UI: one page, the browser does the image work (conversion, editor, preview).
// API: see web/web_ui.cpp. Bitmaps are 84 x 19, as 418 hex digits in drawBitmap format.
static const char WEB_PAGE[] PROGMEM = R"rawliteral(<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FlipDot Display</title>
<style>
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--text)}
header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}
header h1{font-size:18px;margin:0}#statusline{color:var(--dim);font-size:13px}
main{max-width:900px;margin:0 auto;padding:12px 16px}
.mirror{background:#000;border-radius:10px;padding:10px}.mirror canvas{width:100%;display:block}
#mirrorinfo{color:var(--dim);font-size:13px;margin:6px 2px 12px}
nav{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
nav button{background:var(--card);border:1px solid var(--line);color:var(--text);padding:8px 12px;border-radius:8px;cursor:pointer}
nav button.on{border-color:var(--accent);color:var(--accent)}
section{display:none}section.on{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
h2{font-size:15px;margin:0 0 10px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
.item{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer}
.item.cur{border-color:var(--accent)}.item canvas{width:100%;background:#000;border-radius:4px}
.item .name{font-size:13px;margin:6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .tag{color:var(--dim);font-size:11px}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0}
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px}
input[type=checkbox]{transform:scale(1.2)}input[type=range]{padding:0}
button.b{background:var(--accent);color:#000;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 10px;cursor:pointer}
button.d{color:var(--bad)}
#editor{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
.hint{color:var(--dim);font-size:12px}#msg{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px;display:none}
</style></head><body>
<header><h1>FlipDot Display</h1><span id="statusline">connecting...</span></header>
<main>
<div class="mirror"><canvas id="mirror" width="840" height="190"></canvas></div>
<div id="mirrorinfo"></div>
<nav id="tabs"></nav>
<section id="t-show"><div class="card"><h2>What to show during the day</h2>
<div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="grid" id="pages"></div>
<p class="hint">The schedule (Settings) decides when the display is on. The Home Assistant "Clock" switch shows the Nintendo logo when off.</p></div></section>
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<p class="hint" id="storage"></p></div></section>
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
<canvas id="editor" width="840" height="190"></canvas>
<div class="row"><button class="s" onclick="edClear()">Clear</button><button class="s" onclick="edInvert()">Invert</button>
<label><input type="file" id="file" accept="image/*"> Image</label></div>
<div class="row" id="imgopts" style="display:none">
<label>Threshold <input type="range" id="thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="inv"> Invert</label><label><input type="checkbox" id="dith"> Dither</label>
<label>Fit <select id="fit"><option value="contain">fit</option><option value="cover">fill</option><option value="stretch">stretch</option></select></label></div>
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
<label><input type="checkbox" id="autolive"> live while drawing</label>
<button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button></div>
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).</p></div></section>
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
<label>Display on from</label><input type="time" id="s_day">
<label>Late evening from</label><input type="time" id="s_late">
<label>Quarter hours</label><label><input type="checkbox" id="s_q"> late evening: only at :00 :15 :30 :45</label>
<label>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
</div></div>
<div class="card"><h2>Clock</h2><div class="form">
<label>Yawning Pikachu</label><label><input type="checkbox" id="c_ev"> from <input type="time" id="c_evt"></label>
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" when WiFi is down</label>
</div></div>
<div class="card"><h2>Hardware</h2><div class="form">
<label>Flip time</label><label><input type="number" id="h_flip" min="50" max="2000" style="width:6em"> &micro;s (50-2000)</label>
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
</div></div>
<button class="b" onclick="saveSettings()">Save settings</button></section>
<section id="t-ha"><div class="card"><h2>Home Assistant (MQTT)</h2><div class="form">
<label>Enabled</label><input type="checkbox" id="ha_en">
<label>Broker</label><input id="ha_host" placeholder="empty: from secrets.h">
<label>Port</label><input type="number" id="ha_port" min="1" max="65535" style="width:7em">
<label>User</label><input id="ha_user" autocomplete="off">
<label>Password</label><input type="password" id="ha_pass" placeholder="unchanged" autocomplete="new-password">
</div><p class="hint">Saving restarts the ESP32. Entities: Clock and Power switches, Firmware sensor, Recovery button.</p>
<button class="b" onclick="saveHa()">Save and restart</button></div></section>
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
<div class="row" style="margin-top:12px"><button class="s" onclick="post('/api/restart').then(()=>msg('Restarting...'))">Restart</button>
<button class="s d" onclick="if(confirm('Start the recovery app for 10 minutes?'))post('/api/recovery').then(()=>msg('Starting recovery...'))">Recovery app</button></div>
<p class="hint">Firmware updates: <code>pio run -e esp32 -t upload</code></p></div></section>
</main><div id="msg"></div>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[];
const tabs=[['show','Show'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
async function api(p,body){const r=await fetch(p,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));if(!r.ok){msg(j.error||('Error '+r.status),true);throw j}return j}
const post=(p,b)=>api(p,b||{});
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
const hm=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
// mirror of the display
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
setTimeout(pollScreen,document.hidden?5000:1500)}
async function pollStatus(){try{status=await api('/api/status');
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
$('livebox').style.display=status.live?'flex':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
['HA switches','Clock '+(status.clock_enabled?'on':'off')+', Power '+(status.power_enabled?'on':'off')],
['Storage',status.storage.available?Math.round(status.storage.used/1024)+' of '+Math.round(status.storage.total/1024)+' KB used':'not available']];
$('sysinfo').innerHTML='';r.forEach(([k,v])=>{const a=document.createElement('label');a.textContent=k;const b=document.createElement('span');b.textContent=v;$('sysinfo').append(a,b)})}
// pages and bitmaps
async function loadBitmaps(){const l=await api('/api/bitmaps');bitmapList=l.bitmaps;
$('storage').textContent=l.storage?'':'Bitmap storage not available: install the new flash layout (see README).';
const pages=$('pages'),bms=$('bitmaps');pages.innerHTML='';bms.innerHTML='';
pages.appendChild(card('Clock','Pikachu with the time',null,()=>selectPage('clock'),settings&&settings.page=='clock'));
for(const bm of bitmapList){const data=fromHex(bm.data);bm.bits=data;
pages.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectPage(bm.name),settings&&settings.page==bm.name));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=document.createElement('div');r.className='row';
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}}
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
function card(name,tag,bits,onclick,cur){const d=document.createElement('div');d.className='item'+(cur?' cur':'');
if(bits)d.appendChild(mini(bits));else{const c=document.createElement('div');c.style.cssText='height:76px;display:flex;align-items:center;justify-content:center;font-size:28px;background:#000;border-radius:4px;color:#ffd21f';c.textContent='12:34';d.appendChild(c)}
const n=document.createElement('div');n.className='name';n.textContent=name;const t=document.createElement('div');t.className='tag';t.textContent=tag;d.append(n,t);
if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadBitmaps()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');loadBitmaps()}
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// editor
const ed={bits:new Uint8Array(W*H),c:$('editor'),set(b){this.bits=b;draw(this.c,b)}};ed.set(ed.bits);
let painting=null,liveTimer=null,srcImg=null;
function edPos(e){const r=ed.c.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*W),Math.floor((e.clientY-r.top)/r.height*H)]}
ed.c.onpointerdown=e=>{const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;painting=ed.bits[y*W+x]?0:1;ed.c.setPointerCapture(e.pointerId);paint(e)};
ed.c.onpointermove=e=>{if(painting!==null)paint(e)};ed.c.onpointerup=()=>{painting=null};
function paint(e){const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;if(ed.bits[y*W+x]!=painting){ed.bits[y*W+x]=painting;draw(ed.c,ed.bits);changed()}}
function changed(){if(!$('autolive').checked)return;clearTimeout(liveTimer);liveTimer=setTimeout(()=>live(ed.bits),300)}
function edClear(){ed.set(new Uint8Array(W*H));changed()}
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadBitmaps()}
// image conversion
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d');
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.height;let dw=W,dh=H;
if(fit!='stretch'){const s=fit=='contain'?Math.min(W/iw,H/ih):Math.max(W/iw,H/ih);dw=iw*s;dh=ih*s}
g.drawImage(srcImg,(W-dw)/2,(H-dh)/2,dw,dh);const d=g.getImageData(0,0,W,H).data,l=new Float32Array(W*H);
for(let i=0;i<W*H;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
const thr=+$('thr').value,b=new Uint8Array(W*H),dith=$('dith').checked,inv=$('inv').checked;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
if(dith){const e=l[i]-o;if(x+1<W)l[i+1]+=e*7/16;if(y+1<H){if(x>0)l[i+W-1]+=e*3/16;l[i+W]+=e*5/16;if(x+1<W)l[i+W+1]+=e/16}}}
ed.set(b);changed()}
// settings
function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;$('s_late').value=s.schedule.late_start;
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;$('c_ev').checked=s.clock.evening_enabled;$('c_evt').value=s.clock.evening_start;
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none'}
function collect(){const s=JSON.parse(JSON.stringify(settings));
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
Object.assign(s.clock,{evening_enabled:$('c_ev').checked,evening_start:$('c_evt').value,no_wifi_screen:$('c_nw').checked});
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim()});
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;return s}
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
async function saveHa(){await saveSettings()}
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadBitmaps()}
start();
</script></body></html>)rawliteral";
+403
View File
@@ -0,0 +1,403 @@
#include "web/web_ui.h"
#include <ArduinoJson.h>
#include <WebServer.h>
#include <WiFi.h>
#include <time.h>
#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<const char *>())
return;
strncpy(out, value.as<const char *>(), size - 1);
out[size - 1] = '\0';
}
static void copyBool(JsonVariantConst value, bool &out)
{
if (value.is<bool>())
out = value.as<bool>();
}
static bool copyTime(JsonVariantConst value, uint16_t &out)
{
if (value.isNull())
return true;
return parseTime(value.as<const char *>(), 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<JsonObject>();
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<JsonObject>();
ha["enabled"] = appSettings().home_assistant.enabled;
ha["connected"] = homeAssistantConnected();
size_t used, total;
bitmapStorageUsage(used, total);
JsonObject storage = doc["storage"].to<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>());
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<int>())
s.flip_time = body["flip_time"].as<int>();
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<int>())
s.home_assistant.port = ha["port"].as<int>();
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<JsonObject>());
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();
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): 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();
+95
View File
@@ -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).
+328 -25
View File
@@ -12,10 +12,12 @@
#include <vector>
#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<std::string> 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();
}