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

+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;
};