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

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