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

+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).