Add dynamic pages with clock, date, Home Assistant values and fonts

The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page
has a background bitmap per time of day and up to 8 elements (clock, date,
Home Assistant value, text), each with position, alignment, font and overlay.
Pages are drawn on the ESP32, also for the web previews.

- Pages tab in the web UI: page editor with drag and drop, font upload
  (TTF/OTF converted in the browser to the FDF1 dot font format)
- Built-in fonts Small, Normal, Large and Full height (generated by
  tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning
- Home Assistant values over the REST API (URL and token in the HA tab)
- Settings version 4: evening settings removed (now time slots of the Clock
  page), old records are converted
- HA Mode dropdown lists the dynamic pages and bitmaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 19:43:59 +02:00
1 parent 1d3b084de1
commit 0411804a2f
49 files changed
+3058 -331

No files matched your search

+10
View File
@@ -138,6 +138,16 @@ See README.md for the overview.
- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names
are restricted (`validBitmapName`), which also makes them safe in paths and JSON.
- The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`).
- Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a
versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage`
changes). They are drawn only on the ESP32 (`logic/page_render`), also for the web
previews, so there is one renderer. Pages and bitmaps share one namespace (checked in
`app.cpp`); things a page uses can't be deleted. The built-in page "Clock" replaces the
old hardcoded clock (also the default and the HA "Clock" option).
- Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format.
`logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it.
- Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per
`loop()` with `HA_HTTP_TIMEOUT`. Keep it that way (watchdog, OTA).
- Check the page's JavaScript after changes: extract the `<script>` part and run
`node --check` on it (node is installed).
+20 -11
View File
@@ -23,7 +23,7 @@ too, and it boots again each time the relay switches on. Only the ESP32 runs all
| Part | Folder | What it does |
|------|--------|--------------|
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps or your own bitmaps, has a [web UI](#web-ui), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows dynamic pages (a background per time of day with a clock, the date, Home Assistant values or text) or your own bitmaps, has a [web UI](#web-ui), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
@@ -94,21 +94,27 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
| Tab | What you can do |
|-----|-----------------|
| **Show** | Choose what the display shows during the day: the clock (Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. The built-in Nintendo logo can't be deleted. |
| **Show** | Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05` / `8:05`), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after, decimals) and text. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts it. |
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be deleted, nor can a bitmap that a page uses as background. |
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; yawning Pikachu and its time; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. |
| **Home Assistant** | On/off, broker, port, user, password. Saving restarts the ESP32. An empty broker uses `secrets.h`. |
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. |
| **Home Assistant** | On/off, broker, port, user, password (changing these restarts the ESP32; an empty broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic pages (REST API, plain http; empty URL: `http://<broker>:8123`). |
| **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. |
**Live mode** shows the image at once, also at night or before the time is known. It
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
switch still wins: when it is off, nothing is shown.
**Bitmaps** are stored on the ESP32 (LittleFS, 320 KB, room for well over a thousand). They
need the new flash layout from the [one-time installation](#one-time-installation-usb);
**Bitmaps, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB, room for well over
a thousand bitmaps, 50 pages, 20 fonts). Pages and bitmaps share their names. They need
the new flash layout from the [one-time installation](#one-time-installation-usb);
before that, only the built-in ones are available.
**Home Assistant values** on the shown page are read every minute (one entity per loop,
at most about 3 s). An unknown or unreachable value shows `--`. The display only changes
when the shown text changes, so round values with the decimals setting.
Settings are stored in the ESP32's NVS. The defaults are the original behaviour. There is
no login: anyone on your WiFi can use the page, as with OTA.
@@ -289,16 +295,16 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o
connects to MQTT.
2. **Clock.** Once the time is synced, the ESP32 sends a new frame whenever the screen
changes, mostly once a minute:
- **08:00–22:00:** Pikachu with a thought bubble showing `HH:MM`. A yawning Pikachu is
shown from 17:00.
- **08:00–22:00:** the page, by default the built-in Clock page: Pikachu with a thought
bubble showing `HH:MM`, a yawning Pikachu from 17:00 (its time slots).
- **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45.
30 seconds after each of those minutes, the relay turns the display off.
- **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI)
and power is cut after 30 seconds.
3. **Home Assistant** (MQTT discovery):
- `FlipDot Display Power` switch: off clears the display and cuts power through the relay.
- `FlipDot Display Mode` dropdown: what is shown during the day, the clock or one of the
bitmaps (the same choice as in the web UI).
- `FlipDot Display Mode` dropdown: what is shown during the day, a dynamic page ("Clock"
first) or one of the bitmaps (the same choice as in the web UI).
- `FlipDot Display Off image` dropdown: what is left on the dots when the display
switches off (at night or with the Power switch): blank or one of the bitmaps.
- `FlipDot Display IP` sensor: the ESP32's IP address.
@@ -316,6 +322,9 @@ The serial protocol is described in [src/nano/README.md](src/nano/README.md).
Assistant sensor shows "no recovery app".
- [X] **Test the web UI on the real hardware**: live view, uploading and drawing a bitmap,
live mode, choosing a page, settings, Home Assistant settings (restart).
- [ ] **Test the dynamic pages on the real hardware**: the Clock page after the update
(same as before, yawning from 17:00), a new page with date and Full height clock, an
uploaded font, a Home Assistant value (token in the Home Assistant tab).
- [ ] **Test the recovery app on the real hardware**: the Home Assistant button, the setup
WiFi with a wrong WiFi password, 3 quick power cycles, and an OTA upload into it.
+14 -5
View File
@@ -19,7 +19,12 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `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/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts |
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) |
| `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) |
| `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing |
| `logic/builtin_fonts.*` | Small, Normal, Large, Full height; the `.cpp` is generated by `tools/make_builtin_fonts.py` |
| `logic/builtin_pages.*` | The built-in Clock page (the clock of the old firmware) |
| `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 |
@@ -29,19 +34,24 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `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: clock, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
| `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
| `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) |
| `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values |
| `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, ...) |
| `display/bitmaps.h` | 1-bit bitmaps (Nintendo logo, Pikachu with the thought bubble, ...) |
| **`nano_link/`** | **Connection to the Nano** |
| `nano_link/nano_link.*` | Relay and serial link: power on (waits for `READY`), power off, bootloader start (`NanoLink`) |
| `nano_link/nano_flash_bridge.*` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) |
| **`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 (power switch, mode and off image dropdowns, IP and firmware sensors, recovery button) |
| `net/ha_api.*` | Reads Home Assistant entity values over the REST API for the dynamic pages |
| **`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/page_json.*` | Dynamic pages as JSON |
| `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
@@ -127,8 +137,7 @@ Rules for changing the code:
| Time | Display |
|------|---------|
| 08:00 – 17:00 | Pikachu and the clock, updated every minute |
| 17:00 – 22:00 | Yawning Pikachu and the clock |
| 08:00 – 22:00 | The page; the built-in Clock page shows Pikachu and the clock (yawning from 17:00), updated every minute |
| 22:00 – 23:30 | Clock shown only during the minutes :00, :15, :30 and :45. In between the power is off, and the last time stays visible (flipdots keep their image without power). |
| 23:31 – 08:00 | Screen cleared, power off after 30 s |
+143 -32
View File
@@ -1,36 +1,54 @@
#include "app.h"
#include <string.h>
#include <time.h>
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "net/ha_api.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);
FlipdotFrames frames(display, pageResources);
StatusScreen status(display, nano);
DisplayController controller(nano, frames);
static Settings settings;
static bool restart_pending = false;
static uint32_t restart_at = 0;
// Not on the stack: about 1.7 KB
static DynamicPage loaded_page;
// Load the page bitmap into the frames; falls back to the clock when it is gone
static void showDynamicPage()
{
frames.setDynamicPage(loaded_page);
haApiWatch(&loaded_page);
controller.setPage(Page::Dynamic);
}
// Load the page into the frames: a dynamic page or a bitmap; the Clock page when it is gone
static void applyPage()
{
if (strcmp(settings.page, PAGE_CLOCK) != 0)
if (loadPage(settings.page, loaded_page))
{
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);
showDynamicPage();
return;
}
controller.setPage(Page::Clock);
uint8_t bitmap[BITMAP_BYTES];
if (loadBitmap(settings.page, bitmap))
{
frames.setPageBitmap(bitmap);
haApiWatch(nullptr);
controller.setPage(Page::Bitmap);
return;
}
Serial.printf("Page \"%s\" not found, showing the clock\n", settings.page);
builtinPage(DEFAULT_PAGE, loaded_page);
showDynamicPage();
}
// Load the off bitmap into the frames; blank when it is gone
@@ -52,10 +70,18 @@ static void apply()
controller.setSettings(settings.schedule, settings.clock);
controller.setEffectTime(effectTime(settings.drive));
status.setDrive(settings.flip_time, settings.drive);
haApiSetSettings(settings.home_assistant);
applyOffPage();
applyPage();
}
// Something the current page may use changed: load it again and redraw
static void reloadPage()
{
applyPage();
controller.pageChanged();
}
const Settings &appSettings()
{
return settings;
@@ -67,7 +93,7 @@ void appLoadSettings()
apply();
}
static bool sameHomeAssistant(const HomeAssistantSettings &a, const HomeAssistantSettings &b)
static bool sameMqtt(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;
@@ -78,12 +104,12 @@ 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";
if (!pageExists(new_settings.page) && !bitmapExists(new_settings.page))
return "Page not found";
if (new_settings.off_page[0] && !bitmapExists(new_settings.off_page))
return "Off bitmap not found";
restart_needed = !sameHomeAssistant(settings.home_assistant, new_settings.home_assistant);
restart_needed = !sameMqtt(settings.home_assistant, new_settings.home_assistant);
const DriveSettings &a = settings.drive, &b = new_settings.drive;
bool drive_changed = settings.flip_time != new_settings.flip_time || a.flip_gap != b.flip_gap ||
a.pulses != b.pulses || a.effect != b.effect || a.effect_time != b.effect_time ||
@@ -96,12 +122,17 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
return nullptr;
}
static void copyName(char *out, const char *name)
{
strncpy(out, name, PAGE_NAME_SIZE - 1);
out[PAGE_NAME_SIZE - 1] = '\0';
}
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';
if (!pageExists(name) && !bitmapExists(name))
return "Page not found";
copyName(settings.page, name);
saveSettings(settings);
applyPage();
return nullptr;
@@ -111,26 +142,92 @@ const char *appSelectOffPage(const char *name)
{
if (name[0] && !bitmapExists(name))
return "Bitmap not found";
strncpy(settings.off_page, name, sizeof(settings.off_page) - 1);
settings.off_page[sizeof(settings.off_page) - 1] = '\0';
copyName(settings.off_page, name);
saveSettings(settings);
applyOffPage();
controller.pageChanged();
return nullptr;
}
void appBitmapChanged(const char *name)
const char *appSaveBitmap(const char *name, const uint8_t *bitmap)
{
if (strcmp(settings.off_page, name) == 0)
{
if (pageExists(name))
return "A page has this name";
const char *error = saveBitmap(name, bitmap);
if (error)
return error;
if (sameName(settings.off_page, name))
applyOffPage();
controller.pageChanged();
}
if (strcmp(settings.page, name) == 0)
{
applyPage();
controller.pageChanged();
}
reloadPage(); // the page or its background
return nullptr;
}
// "Used by page X ..." (the returned text stays valid until the next call)
static const char *usedBy(const String &page)
{
static char message[80];
snprintf(message, sizeof(message), "Used by page \"%s\": change that page first", page.c_str());
return message;
}
const char *appDeleteBitmap(const char *name)
{
String user = pageUsingBitmap(name);
if (user.length())
return usedBy(user);
const char *error = deleteBitmap(name);
if (error)
return error;
if (sameName(settings.page, name))
appSelectPage(DEFAULT_PAGE);
if (sameName(settings.off_page, name))
appSelectOffPage(OFF_PAGE_BLANK);
return nullptr;
}
const char *appSavePage(const char *name, const DynamicPage &page)
{
if (bitmapExists(name))
return "A bitmap has this name";
for (int i = 0; i < page.slot_count; i++)
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap))
return "Background bitmap not found";
for (int i = 0; i < page.element_count; i++)
if (!fontExists(page.elements[i].font))
return "Font not found";
const char *error = savePage(name, page);
if (error)
return error;
if (sameName(settings.page, name))
reloadPage();
return nullptr;
}
const char *appDeletePage(const char *name)
{
const char *error = deletePage(name);
if (error)
return error;
if (sameName(settings.page, name))
appSelectPage(DEFAULT_PAGE);
return nullptr;
}
const char *appSaveFont(const char *name, const uint8_t *data, size_t size)
{
const char *error = saveFont(name, data, size);
if (error)
return error;
controller.pageChanged(); // the page may use it
return nullptr;
}
const char *appDeleteFont(const char *name)
{
String user = pageUsingFont(name);
if (user.length())
return usedBy(user);
return deleteFont(name);
}
void appShowLive(const uint8_t *bitmap)
@@ -144,6 +241,20 @@ void appStopLive()
controller.stopLive();
}
bool appLocalTime(LocalTime &time)
{
struct tm now = {};
if (!getLocalTime(&now, 0))
return false;
time.year = now.tm_year + 1900;
time.month = now.tm_mon + 1;
time.day = now.tm_mday;
time.weekday = now.tm_wday;
time.hour = now.tm_hour;
time.minute = now.tm_min;
return true;
}
void appRestartLater(uint32_t delay_ms)
{
restart_pending = true;
+17 -4
View File
@@ -7,6 +7,8 @@
#include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h"
#include "logic/local_time.h"
#include "logic/page.h"
#include "logic/settings.h"
#include "nano_link/nano_link.h"
@@ -20,20 +22,31 @@ extern DisplayController controller;
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).
// restart_needed when the change only works after a restart (Home Assistant MQTT).
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)
// The page shown during the day: a dynamic page or a bitmap (stored in the settings)
const char *appSelectPage(const char *name);
// Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
const char *appSelectOffPage(const char *name);
// A bitmap was saved or deleted: redraw when it is the current page or off image
void appBitmapChanged(const char *name);
// Bitmaps, dynamic pages and fonts: stored, and the display redrawn when it shows them.
// Bitmaps and pages share one namespace; what a page uses can't be deleted. All return
// nullptr or an error message.
const char *appSaveBitmap(const char *name, const uint8_t *bitmap);
const char *appDeleteBitmap(const char *name);
const char *appSavePage(const char *name, const DynamicPage &page);
const char *appDeletePage(const char *name);
const char *appSaveFont(const char *name, const uint8_t *data, size_t size);
const char *appDeleteFont(const char *name);
// Live mode of the web UI
void appShowLive(const uint8_t *bitmap);
void appStopLive();
// The local time; false until it is synced over NTP
bool appLocalTime(LocalTime &time);
// Restart the ESP32 after `delay_ms` (so a web response can still be sent)
void appRestartLater(uint32_t delay_ms);
void appLoop();
+5 -1
View File
@@ -45,6 +45,10 @@ const uint32_t NO_WIFI_SHOW_TIME = 60000;
const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000;
// Power off this long after the display is no longer needed (night, standby)
const uint32_t POWER_OFF_DELAY = 30000;
// Home Assistant values on dynamic pages (net/ha_api): read this often, and the HTTP
// timeout (a read blocks loop() for at most about twice this)
const uint32_t HA_POLL_INTERVAL = 60000;
const uint32_t HA_HTTP_TIMEOUT = 1500;
// ---------------------------------------------------------------------------
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
@@ -95,7 +99,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.3.0"
#define FIRMWARE_VERSION "2.4.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+26 -13
View File
@@ -15,6 +15,8 @@ struct BuiltinBitmap
static const BuiltinBitmap BUILTIN[] = {
{"Nintendo", nintendo_bitmap_Nintendo_new},
{"Pikachu", pikachu_clock_bitmap},
{"Pikachu yawning", pikachu_yawning_clock_bitmap},
};
static bool mounted = false;
@@ -36,8 +38,9 @@ static String path(const char *name)
bool bitmapStoreBegin()
{
mounted = LittleFS.begin(true);
if (mounted && !LittleFS.exists(DIR))
LittleFS.mkdir(DIR);
for (const char *dir : {DIR, "/pages", "/fonts"})
if (mounted && !LittleFS.exists(dir))
LittleFS.mkdir(dir);
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
return mounted;
}
@@ -53,27 +56,37 @@ void bitmapStorageUsage(size_t &used, size_t &total)
total = mounted ? LittleFS.totalBytes() : 0;
}
std::vector<BitmapInfo> listBitmaps()
std::vector<String> storedNames(const char *dir_path, const char *extension)
{
std::vector<BitmapInfo> list;
for (const BuiltinBitmap &b : BUILTIN)
list.push_back({b.name, true});
std::vector<String> names;
if (!mounted)
return list;
File dir = LittleFS.open(DIR);
return names;
File dir = LittleFS.open(dir_path);
if (!dir)
return names;
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"))
if (!name.endsWith(extension))
continue;
name.remove(name.length() - 4);
if (validBitmapName(name.c_str()) && !builtin(name.c_str()))
list.push_back({name, false});
name.remove(name.length() - strlen(extension));
if (validBitmapName(name.c_str()))
names.push_back(name);
}
return names;
}
std::vector<BitmapInfo> listBitmaps()
{
std::vector<BitmapInfo> list;
for (const BuiltinBitmap &b : BUILTIN)
list.push_back({b.name, true});
for (const String &name : storedNames(DIR, ".bin"))
if (!builtin(name.c_str()))
list.push_back({name, false});
return list;
}
+5 -1
View File
@@ -7,7 +7,8 @@
// 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.
// (partition "spiffs" in partitions.csv) as /bitmaps/<name>.bin. Bitmaps and dynamic
// pages share one namespace: app.cpp checks that a new name is not taken.
struct BitmapInfo
{
@@ -20,8 +21,11 @@ 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();
// (LittleFS is shared with display/page_store and display/font_store)
bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
// The valid names of the files with this extension in a LittleFS directory
std::vector<String> storedNames(const char *dir, const char *extension);
std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted
+32 -28
View File
@@ -39,34 +39,38 @@ const unsigned char yawning_pikachu_bitmap_yawning_pikachu [] PROGMEM = {
0xfe, 0xff, 0xf7, 0x6f, 0xe0, 0xfe, 0x7f, 0xfb, 0x8f, 0xe0, 0xfe, 0xfe, 0x0b, 0xaf, 0xe0
};
// 'yawning_pikachu_black_border', 35x19px
const unsigned char yawning_pikachu_bitmap_yawning_pikachu_black_border [] PROGMEM = {
0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07,
0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x7f, 0xff, 0xdf, 0x80, 0x7f, 0x73,
0xf9, 0xdf, 0xc0, 0xfe, 0xcf, 0xbe, 0x6f, 0xe0, 0xfd, 0xff, 0xff, 0xf7, 0xe0, 0x01, 0xff, 0x1f,
0xf0, 0x00, 0x03, 0x9e, 0x0f, 0x38, 0x00, 0x03, 0x0e, 0xee, 0x18, 0x00, 0x03, 0x0e, 0xee, 0x18,
0x00, 0x03, 0x9e, 0xe3, 0x38, 0x00, 0x03, 0xff, 0x0d, 0xf8, 0x00, 0x01, 0xff, 0xee, 0xf0, 0x00,
0x01, 0xff, 0xf7, 0x70, 0x00, 0x00, 0x7f, 0xfb, 0x80, 0x00, 0x01, 0xfe, 0x0b, 0xb0, 0x00
// 'Pikachu', 84x19px: Pikachu with an empty thought bubble (background of the Clock page)
const unsigned char pikachu_clock_bitmap[] PROGMEM = {
0x00, 0x00, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xe0, 0x00,
0x3f, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0xff, 0xff, 0xff, 0x80, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0xff, 0xff, 0xff,
0xe0, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0xee, 0x7f, 0x3b, 0xf0, 0x80, 0x00, 0x00, 0x00,
0x01, 0x00, 0x0f, 0xed, 0x3e, 0x9b, 0xf8, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xdc, 0x3e,
0x1d, 0xfc, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xbe, 0x7f, 0x3e, 0xfc, 0x80, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x3f, 0xf7, 0xfe, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x73,
0xff, 0xe7, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xff, 0xc3, 0x00, 0x80, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xff, 0xc3, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
0x73, 0xb6, 0xe7, 0x00, 0x20, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x7f, 0xc9, 0xff, 0x00, 0x41,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xff, 0xfe, 0x01, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xf0, 0x02,
0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xc1, 0xfe, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00,
};
// 'clock thought bubble', 46x19px
const unsigned char clock_thought_bubble_bitmap_clock_thought_bubble [] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x00, 0x10, 0x00, 0x00, 0x00,
0x00, 0x80, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00,
0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40,
0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00,
0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x10, 0x00, 0x00, 0x00, 0x00, 0x80, 0x08, 0x3f,
0xff, 0xff, 0xff, 0x00, 0x10, 0x40, 0x00, 0x00, 0x00, 0x00, 0x21, 0x80, 0x00, 0x00, 0x00, 0x00,
0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00,
0x00, 0x00
};
// 'pikachu_black_border', 35x19px
const unsigned char pikachu_bitmap_pikachu_black_border [] PROGMEM = {
0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07,
0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x73, 0xf9, 0xdf, 0x80, 0x7f, 0x69,
0xf4, 0xdf, 0xc0, 0xfe, 0xe1, 0xf0, 0xef, 0xe0, 0xfd, 0xf3, 0xf9, 0xf7, 0xe0, 0x01, 0xff, 0xbf,
0xf0, 0x00, 0x03, 0x9f, 0xff, 0x38, 0x00, 0x03, 0x0f, 0xfe, 0x18, 0x00, 0x03, 0x0f, 0xfe, 0x18,
0x00, 0x03, 0x9d, 0xb7, 0x38, 0x00, 0x03, 0xfe, 0x4f, 0xf8, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00,
0x01, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x7f, 0xff, 0x80, 0x00, 0x01, 0xfe, 0x0f, 0xf0, 0x00
// 'Pikachu yawning', 84x19px: Pikachu with an empty thought bubble (background of the Clock page)
const unsigned char pikachu_yawning_clock_bitmap[] PROGMEM = {
0x00, 0x00, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xe0, 0x00,
0x3f, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0xff, 0xff, 0xff, 0x80, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0xff, 0xff, 0xff,
0xe0, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0xef, 0xff, 0xfb, 0xf0, 0x80, 0x00, 0x00, 0x00,
0x01, 0x00, 0x0f, 0xee, 0x7f, 0x3b, 0xf8, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xd9, 0xf7,
0xcd, 0xfc, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xbf, 0xff, 0xfe, 0xfc, 0x80, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x3f, 0xe3, 0xfe, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x73,
0xc1, 0xe7, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xdd, 0xc3, 0x00, 0x80, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xdd, 0xc3, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
0x73, 0xdc, 0x67, 0x00, 0x20, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x7f, 0xe1, 0xbf, 0x00, 0x41,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xfd, 0xde, 0x00, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xfe, 0xee, 0x01, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0x70, 0x02,
0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xc1, 0x76, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00,
};
+114
View File
@@ -0,0 +1,114 @@
#include "display/font_store.h"
#include <LittleFS.h>
#include <map>
#include "display/bitmap_store.h"
#include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
static const char *DIR = "/fonts";
static const char *EXTENSION = ".fnt";
static uint32_t revision = 0;
// Loaded fonts by name; cleared when a font is saved or deleted
static std::map<String, std::vector<uint8_t>> cache;
static String path(const char *name)
{
return String(DIR) + "/" + name + EXTENSION;
}
std::vector<FontInfo> listFonts()
{
std::vector<FontInfo> list;
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
list.push_back({BUILTIN_FONTS[i].name, true, (uint8_t)fontHeight(builtinFont(BUILTIN_FONTS[i]))});
for (const String &name : storedNames(DIR, EXTENSION))
if (!findBuiltinFont(name.c_str()))
list.push_back({name, false, (uint8_t)fontHeight(findFont(name.c_str()))});
return list;
}
uint32_t fontListRevision()
{
return revision;
}
bool fontExists(const char *name)
{
if (findBuiltinFont(name))
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
Font findFont(const char *name)
{
const BuiltinFont *builtin = findBuiltinFont(name);
if (builtin)
return builtinFont(*builtin);
Font font;
if (!bitmapStorageAvailable() || !validBitmapName(name))
return font;
auto cached = cache.find(name);
if (cached == cache.end())
{
if (cache.size() >= 16)
cache.clear();
std::vector<uint8_t> data;
File file;
if (LittleFS.exists(path(name))) // open() logs an error for a missing file
file = LittleFS.open(path(name), "r");
if (file && file.size() <= FONT_MAX_SIZE)
{
data.resize(file.size());
if (file.read(data.data(), data.size()) != data.size() || !validFont(data.data(), data.size()))
data.clear();
}
if (file)
file.close();
cached = cache.emplace(name, std::move(data)).first; // also remembers a missing font
}
if (!cached->second.empty())
{
font.data = cached->second.data();
font.size = cached->second.size();
}
return font;
}
const char *saveFont(const char *name, const uint8_t *data, size_t size)
{
if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (findBuiltinFont(name))
return "A built-in font has this name";
if (!validFont(data, size))
return "Invalid font data";
if (!bitmapStorageAvailable())
return "No storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && storedNames(DIR, EXTENSION).size() >= (size_t)MAX_FONTS)
return "Too many fonts, delete some first";
File file = LittleFS.open(path(name), "w");
bool ok = file && file.write(data, size) == size;
if (file)
file.close();
cache.clear();
revision++;
return ok ? nullptr : "Could not write the file (storage full?)";
}
const char *deleteFont(const char *name)
{
if (findBuiltinFont(name))
return "Built-in fonts can't be deleted";
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Font not found";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
cache.clear();
revision++;
return nullptr;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "logic/font.h"
// Fonts of the dynamic pages: the built-in ones (logic/builtin_fonts.h) and the ones
// converted from TTF/OTF in the web UI, stored on LittleFS as /fonts/<name>.fnt in the
// format of logic/font.h. Font names have their own namespace.
struct FontInfo
{
String name;
bool builtin;
uint8_t height; // in dots (scaled)
};
const int MAX_FONTS = 20;
// Built-in fonts first, then the stored ones
std::vector<FontInfo> listFonts();
// Changes each time a font is saved or deleted
uint32_t fontListRevision();
bool fontExists(const char *name);
// A font by name: built-in, or loaded from LittleFS and kept in memory. A font without
// data when it is gone. The data stays valid until the next findFont(), saveFont() or
// deleteFont().
Font findFont(const char *name);
// Return nullptr on success, otherwise a message for the user
const char *saveFont(const char *name, const uint8_t *data, size_t size);
const char *deleteFont(const char *name);
+10 -21
View File
@@ -3,33 +3,17 @@
#include <stdio.h>
#include <string.h>
#include "display/bitmaps.h"
#include "display/text.h"
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
static const int CLOCK_X = 3;
FlipdotFrames::FlipdotFrames(BROSE9323 &display) : _display(display)
FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _display(display), _resources(resources)
{
}
void FlipdotFrames::clock(int hour, int minute, bool evening)
void FlipdotFrames::dynamicPage(const LocalTime &time)
{
char time[6];
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
_display.fillScreen(0);
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);
_display.drawBitmap(CLOCK_X + 35, 0, clock_thought_bubble_bitmap_clock_thought_bubble, 46, 19, 1);
_display.setTextSize(1);
_display.setTextWrap(false);
_display.setCursor(CLOCK_X + 43, 4);
_display.print(time);
_display.display();
uint8_t bitmap[BITMAP_BYTES];
renderPage(_dynamic, time, _resources, bitmap);
_drawFullScreen(bitmap);
}
void FlipdotFrames::noWifi(int hour, int minute)
@@ -55,6 +39,11 @@ void FlipdotFrames::off()
_drawFullScreen(_off);
}
void FlipdotFrames::setDynamicPage(const DynamicPage &page)
{
_dynamic = page;
}
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
{
memcpy(_page, bitmap, BITMAP_BYTES);
+7 -3
View File
@@ -3,15 +3,16 @@
#include "BROSE9323.h"
#include "logic/bitmap_format.h"
#include "logic/display_controller.h"
#include "logic/page_render.h"
// Draws the screens on the flipdot display. Each method sends one complete frame.
class FlipdotFrames : public Frames
{
public:
explicit FlipdotFrames(BROSE9323 &display);
FlipdotFrames(BROSE9323 &display, PageResources &resources);
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute, bool evening) override;
// The page set with setDynamicPage(), drawn for this time
void dynamicPage(const LocalTime &time) override;
// "No WiFi" with the time below it
void noWifi(int hour, int minute) override;
// The bitmap set with setPageBitmap()
@@ -21,6 +22,7 @@ public:
// Blank, or the bitmap set with setOffBitmap()
void off() override;
void setDynamicPage(const DynamicPage &page);
void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap);
// nullptr: blank
@@ -28,6 +30,8 @@ public:
private:
BROSE9323 &_display;
PageResources &_resources;
DynamicPage _dynamic;
uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
+22
View File
@@ -0,0 +1,22 @@
#include "display/page_resources.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "net/ha_api.h"
StoredPageResources pageResources;
bool StoredPageResources::background(const char *name, uint8_t *bitmap)
{
return loadBitmap(name, bitmap);
}
Font StoredPageResources::font(const char *name)
{
return findFont(name);
}
const char *StoredPageResources::haValue(const PageElement &element)
{
return haApiValue(element.entity, element.attribute);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "logic/page_render.h"
// Bitmaps from display/bitmap_store, fonts from display/font_store and Home Assistant
// values from net/ha_api, for drawing dynamic pages on the display and in the web UI
class StoredPageResources : public PageResources
{
public:
bool background(const char *name, uint8_t *bitmap) override;
Font font(const char *name) override;
const char *haValue(const PageElement &element) override;
};
extern StoredPageResources pageResources;
+150
View File
@@ -0,0 +1,150 @@
#include "display/page_store.h"
#include <LittleFS.h>
#include "display/bitmap_store.h"
#include "logic/builtin_pages.h"
static const char *DIR = "/pages";
static const char *EXTENSION = ".pg";
// Increase when DynamicPage changes (older files are then ignored)
static const uint16_t VERSION = 1;
struct StoredPage
{
uint16_t version;
uint16_t size;
DynamicPage page;
};
static uint32_t revision = 0;
static String path(const char *name)
{
return String(DIR) + "/" + name + EXTENSION;
}
std::vector<PageInfo> listPages()
{
std::vector<PageInfo> list;
for (int i = 0; i < builtinPageCount(); i++)
list.push_back({builtinPageName(i), true});
for (const String &name : storedNames(DIR, EXTENSION))
if (!builtinPageExists(name.c_str()))
list.push_back({name, false});
return list;
}
uint32_t pageListRevision()
{
return revision;
}
bool builtinPageExists(const char *name)
{
for (int i = 0; i < builtinPageCount(); i++)
if (sameName(builtinPageName(i), name))
return true;
return false;
}
bool pageExists(const char *name)
{
if (builtinPageExists(name))
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
bool loadPage(const char *name, DynamicPage &page)
{
if (builtinPage(name, page))
return true;
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return false;
File file = LittleFS.open(path(name), "r");
if (!file)
return false;
StoredPage *stored = new StoredPage;
bool ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) &&
stored->version == VERSION && stored->size == sizeof(DynamicPage) &&
validatePage(stored->page) == nullptr;
file.close();
if (ok)
page = stored->page;
delete stored;
return ok;
}
const char *savePage(const char *name, const DynamicPage &page)
{
if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (builtinPageExists(name))
return "A built-in page has this name";
const char *error = validatePage(page);
if (error)
return error;
if (!bitmapStorageAvailable())
return "No storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && storedNames(DIR, EXTENSION).size() >= (size_t)MAX_PAGES)
return "Too many pages, delete some first";
StoredPage *stored = new StoredPage;
stored->version = VERSION;
stored->size = sizeof(DynamicPage);
stored->page = page;
File file = LittleFS.open(path(name), "w");
bool ok = file && file.write((const uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage);
if (file)
file.close();
delete stored;
revision++;
return ok ? nullptr : "Could not write the file (storage full?)";
}
const char *deletePage(const char *name)
{
if (builtinPageExists(name))
return "Built-in pages can't be deleted";
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Page not found";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
revision++;
return nullptr;
}
// The first stored page for which `uses` is true
template <typename F> static String findPage(F uses)
{
DynamicPage *page = new DynamicPage;
String found;
for (const String &name : storedNames(DIR, EXTENSION))
if (loadPage(name.c_str(), *page) && uses(*page))
{
found = name;
break;
}
delete page;
return found;
}
String pageUsingBitmap(const char *bitmap)
{
return findPage([bitmap](const DynamicPage &page) {
for (int i = 0; i < page.slot_count; i++)
if (sameName(page.slots[i].bitmap, bitmap))
return true;
return false;
});
}
String pageUsingFont(const char *font)
{
return findPage([font](const DynamicPage &page) {
for (int i = 0; i < page.element_count; i++)
if (sameName(page.elements[i].font, font))
return true;
return false;
});
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "logic/page.h"
// Dynamic pages (logic/page.h): the built-in ones (logic/builtin_pages.h) and the ones
// made in the web UI, stored on LittleFS as /pages/<name>.pg (the struct with a version).
// Pages and bitmaps share one namespace: app.cpp checks that a new name is not taken.
struct PageInfo
{
String name;
bool builtin;
};
const int MAX_PAGES = 50;
// Built-in pages first, then the stored ones
std::vector<PageInfo> listPages();
// Changes each time a page is saved or deleted
uint32_t pageListRevision();
bool pageExists(const char *name);
bool builtinPageExists(const char *name);
bool loadPage(const char *name, DynamicPage &page);
// Return nullptr on success, otherwise a message for the user. savePage() validates the
// page, but does not check that its bitmaps and fonts exist.
const char *savePage(const char *name, const DynamicPage &page);
const char *deletePage(const char *name);
// The first stored page that uses the bitmap (as background) or the font; empty if none
String pageUsingBitmap(const char *bitmap);
String pageUsingFont(const char *font);
+9 -5
View File
@@ -73,9 +73,13 @@ bool validBitmapName(const char *name)
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;
return true;
}
bool sameName(const char *a, const char *b)
{
for (; *a && *b; a++, b++)
if (tolower((unsigned char)*a) != tolower((unsigned char)*b))
return false;
return *a == *b;
}
+4 -2
View File
@@ -20,7 +20,9 @@ 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)
// Names of bitmaps, dynamic pages and fonts: 1-24 characters, letters, digits, space, '-'
// and '_', no space at the start or end. Safe in file paths and JSON.
const int BITMAP_NAME_MAX = 24;
bool validBitmapName(const char *name);
// Names are compared without case (bitmaps and pages share one namespace)
bool sameName(const char *a, const char *b);
+173
View File
@@ -0,0 +1,173 @@
// Generated by tools/make_builtin_fonts.py, do not edit.
// Small: TomThumb (BSD license, Brian J. Swetland, Vassilii Khachaturov, Dan Marks,
// Robey Pointer); Normal: Adafruit GFX glcdfont (BSD license, Adafruit Industries).
#include "logic/builtin_fonts.h"
#include "logic/bitmap_format.h"
static const uint8_t FONT_SMALL[] = {
0x46, 0x44, 0x46, 0x31, 0x06, 0x01, 0x20, 0x5f, 0x02, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x01,
0x02, 0x02, 0x03, 0x03, 0x02, 0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x01, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x80,
0x00, 0x80, 0x00, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xe0, 0xa0, 0xe0, 0xa0, 0x00, 0x60,
0xc0, 0x60, 0xc0, 0x40, 0x00, 0x80, 0x20, 0x40, 0x80, 0x20, 0x00, 0xc0, 0xc0, 0xe0, 0xa0, 0x60,
0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x80, 0x80, 0x40, 0x00, 0x80, 0x40, 0x40,
0x40, 0x80, 0x00, 0xa0, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x40, 0xe0, 0x40, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40, 0x80, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x00, 0x20, 0x20, 0x40, 0x80, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0x20, 0x60, 0x20,
0x20, 0x20, 0x00, 0xc0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xc0, 0x20, 0x40, 0x20, 0xc0, 0x00, 0xa0,
0xa0, 0xe0, 0x20, 0x20, 0x00, 0xe0, 0x80, 0xc0, 0x20, 0xc0, 0x00, 0x60, 0x80, 0xe0, 0xa0, 0xe0,
0x00, 0xe0, 0x20, 0x40, 0x80, 0x80, 0x00, 0xe0, 0xa0, 0xe0, 0xa0, 0xe0, 0x00, 0xe0, 0xa0, 0xe0,
0x20, 0xc0, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x80, 0x00, 0x20,
0x40, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0x80, 0x40, 0x20, 0x40, 0x80,
0x00, 0xe0, 0x20, 0x40, 0x00, 0x40, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x60, 0x00, 0x40, 0xa0, 0xe0,
0xa0, 0xa0, 0x00, 0xc0, 0xa0, 0xc0, 0xa0, 0xc0, 0x00, 0x60, 0x80, 0x80, 0x80, 0x60, 0x00, 0xc0,
0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0x80,
0x00, 0x60, 0x80, 0xe0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x00, 0xe0, 0x40, 0x40,
0x40, 0xe0, 0x00, 0x20, 0x20, 0x20, 0xa0, 0x40, 0x00, 0xa0, 0xa0, 0xc0, 0xa0, 0xa0, 0x00, 0x80,
0x80, 0x80, 0x80, 0xe0, 0x00, 0xa0, 0xe0, 0xe0, 0xa0, 0xa0, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0xa0,
0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x40, 0x00, 0xc0, 0xa0, 0xc0, 0x80, 0x80, 0x00, 0x40, 0xa0, 0xa0,
0xe0, 0x60, 0x00, 0xc0, 0xa0, 0xe0, 0xc0, 0xa0, 0x00, 0x60, 0x80, 0x40, 0x20, 0xc0, 0x00, 0xe0,
0x40, 0x40, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xa0, 0x40, 0x40,
0x00, 0xa0, 0xa0, 0xe0, 0xe0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0xa0, 0xa0, 0x00, 0xa0, 0xa0, 0x40,
0x40, 0x40, 0x00, 0xe0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0x80, 0x80, 0xe0, 0x00, 0x00,
0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x40, 0xa0, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x80, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x60,
0xa0, 0xe0, 0x00, 0x80, 0xc0, 0xa0, 0xa0, 0xc0, 0x00, 0x00, 0x60, 0x80, 0x80, 0x60, 0x00, 0x20,
0x60, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0x60, 0xa0, 0xc0, 0x60, 0x00, 0x20, 0x40, 0xe0, 0x40, 0x40,
0x00, 0x00, 0x60, 0xa0, 0xe0, 0x20, 0x40, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0x00, 0x80, 0x00, 0x80,
0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x20, 0xa0, 0x40, 0x80, 0xa0, 0xc0, 0xc0, 0xa0, 0x00, 0xc0,
0x40, 0x40, 0x40, 0xe0, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xa0, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xa0,
0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xc0, 0x80, 0x00, 0x60, 0xa0,
0xa0, 0x60, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x00, 0x00, 0x60, 0xc0, 0x60, 0xc0, 0x00, 0x40,
0xe0, 0x40, 0x40, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xe0, 0x40,
0x00, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0xa0, 0x40, 0x40, 0xa0, 0x00, 0x00, 0xa0, 0xa0,
0x60, 0x20, 0x40, 0x00, 0xe0, 0x60, 0xc0, 0xe0, 0x00, 0x60, 0x40, 0x80, 0x40, 0x60, 0x00, 0x80,
0x80, 0x00, 0x80, 0x80, 0x00, 0xc0, 0x40, 0x20, 0x40, 0xc0, 0x00, 0x60, 0xc0, 0x00, 0x00, 0x00,
0x00,
};
static const uint8_t FONT_NORMAL[] = {
0x46, 0x44, 0x46, 0x31, 0x08, 0x01, 0x20, 0x5f, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20,
0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50,
0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x20, 0x78, 0xa0, 0x70, 0x28, 0xf0, 0x20, 0x00, 0xc0,
0xc8, 0x10, 0x20, 0x40, 0x98, 0x18, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa8, 0x90, 0x68, 0x00, 0x30,
0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10, 0x00, 0x40,
0x20, 0x10, 0x10, 0x10, 0x20, 0x40, 0x00, 0x20, 0xa8, 0x70, 0xf8, 0x70, 0xa8, 0x20, 0x00, 0x00,
0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x20, 0x40, 0x00,
0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, 0x00,
0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00, 0x20,
0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x70, 0x88, 0x08, 0x70, 0x80, 0x80, 0xf8, 0x00, 0xf8,
0x08, 0x10, 0x30, 0x08, 0x88, 0x70, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00, 0xf8,
0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x38, 0x40, 0x80, 0xf0, 0x88, 0x88, 0x70, 0x00, 0xf8,
0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00, 0x70,
0x88, 0x88, 0x78, 0x08, 0x10, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00,
0x00, 0x20, 0x00, 0x20, 0x20, 0x40, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00,
0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x00, 0x70,
0x88, 0x08, 0x30, 0x20, 0x00, 0x20, 0x00, 0x70, 0x88, 0xa8, 0xb8, 0xb0, 0x80, 0x78, 0x00, 0x20,
0x50, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00, 0x70,
0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00, 0xf8,
0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x78,
0x88, 0x80, 0x80, 0x98, 0x88, 0x78, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, 0x70,
0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x88,
0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00, 0x88,
0xd8, 0xa8, 0xa8, 0xa8, 0x88, 0x88, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00, 0x70,
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x70,
0x88, 0x88, 0x88, 0xa8, 0x90, 0x68, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x00, 0x70,
0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0xf8, 0xa8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x88,
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x88,
0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00, 0x88,
0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0xf8, 0x08, 0x10, 0x70, 0x40, 0x80, 0xf8, 0x00, 0x78,
0x40, 0x40, 0x40, 0x40, 0x40, 0x78, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00, 0x78,
0x08, 0x08, 0x08, 0x08, 0x08, 0x78, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x60, 0x60, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x60, 0x10, 0x70, 0x90, 0x78, 0x00, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0xc8, 0xb0, 0x00, 0x00,
0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x08, 0x08, 0x68, 0x98, 0x88, 0x98, 0x68, 0x00, 0x00,
0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x10, 0x28, 0x20, 0x70, 0x20, 0x20, 0x20, 0x00, 0x00,
0x00, 0x70, 0x98, 0x98, 0x68, 0x08, 0x70, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x20,
0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x10, 0x00, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x80,
0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00,
0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x00,
0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0xc8, 0xb0, 0x80, 0x80, 0x00,
0x00, 0x68, 0x98, 0x98, 0x68, 0x08, 0x08, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00, 0x00,
0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x28, 0x10, 0x00, 0x00,
0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x00,
0x00, 0x88, 0x88, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00, 0x00,
0x00, 0x88, 0x88, 0x78, 0x08, 0x88, 0x70, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00, 0x10,
0x20, 0x20, 0x40, 0x20, 0x20, 0x10, 0x00, 0x20, 0x20, 0x20, 0x00, 0x20, 0x20, 0x20, 0x00, 0x40,
0x20, 0x20, 0x10, 0x20, 0x20, 0x40, 0x00, 0x40, 0xa8, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
};
static const uint8_t FONT_FULL[] = {
0x46, 0x44, 0x46, 0x31, 0x13, 0x02, 0x20, 0x5f, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x03, 0x00, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a,
0x0a, 0x0a, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xfc, 0xfc, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0,
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x01, 0x80, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0xff, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0,
0xff, 0x80, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0xc0,
0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x61, 0x80, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80,
0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80,
0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00,
0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80,
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x00, 0x00, 0x00, 0x00,
0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00,
};
const BuiltinFont BUILTIN_FONTS[] = {
{"Small", FONT_SMALL, sizeof(FONT_SMALL), 1},
{"Normal", FONT_NORMAL, sizeof(FONT_NORMAL), 1},
{"Large", FONT_NORMAL, sizeof(FONT_NORMAL), 2},
{"Full height", FONT_FULL, sizeof(FONT_FULL), 1},
};
const int BUILTIN_FONT_COUNT = sizeof(BUILTIN_FONTS) / sizeof(BUILTIN_FONTS[0]);
const BuiltinFont *findBuiltinFont(const char *name)
{
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
if (sameName(BUILTIN_FONTS[i].name, name))
return &BUILTIN_FONTS[i];
return nullptr;
}
Font builtinFont(const BuiltinFont &font)
{
Font f;
f.data = font.data;
f.size = font.size;
f.scale = font.scale;
return f;
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "logic/font.h"
// The built-in fonts of the dynamic pages (data in builtin_fonts.cpp, generated by
// tools/make_builtin_fonts.py): "Small" (3x5), "Normal" (5x7, the old clock font),
// "Large" (Normal x 2) and "Full height" (19 dot digits, only 0-9 : . - and space).
struct BuiltinFont
{
const char *name;
const uint8_t *data;
size_t size;
uint8_t scale;
};
extern const BuiltinFont BUILTIN_FONTS[];
extern const int BUILTIN_FONT_COUNT;
#define FONT_NORMAL_NAME "Normal"
// nullptr when there is no built-in font with this name (compared without case)
const BuiltinFont *findBuiltinFont(const char *name);
Font builtinFont(const BuiltinFont &font);
+45
View File
@@ -0,0 +1,45 @@
#include "logic/builtin_pages.h"
#include <string.h>
#include "logic/builtin_fonts.h"
static const char *const NAMES[] = {BUILTIN_PAGE_CLOCK};
int builtinPageCount()
{
return sizeof(NAMES) / sizeof(NAMES[0]);
}
const char *builtinPageName(int index)
{
return index >= 0 && index < builtinPageCount() ? NAMES[index] : nullptr;
}
static void clockPage(DynamicPage &page)
{
page = DynamicPage();
page.slot_count = 2;
page.slots[0].start = 0;
strcpy(page.slots[0].bitmap, "Pikachu");
page.slots[1].start = 17 * 60;
strcpy(page.slots[1].bitmap, "Pikachu yawning");
// The same spot as the old clock: in the thought bubble
page.element_count = 1;
PageElement &clock = page.elements[0];
clock.type = ElementType::Clock;
clock.x = 46;
clock.y = 4;
strcpy(clock.font, FONT_NORMAL_NAME);
}
bool builtinPage(const char *name, DynamicPage &page)
{
if (name && sameName(name, BUILTIN_PAGE_CLOCK))
{
clockPage(page);
return true;
}
return false;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "logic/page.h"
// The built-in dynamic pages. They can't be changed or deleted; the web UI saves an edited
// copy under a new name.
//
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of
// the old firmware; also the default page)
#define BUILTIN_PAGE_CLOCK "Clock"
int builtinPageCount();
const char *builtinPageName(int index);
// False when there is no built-in page with this name (compared without case)
bool builtinPage(const char *name, DynamicPage &page);
+13 -14
View File
@@ -11,7 +11,7 @@ void DisplayController::setSettings(const ScheduleSettings &schedule, const Cloc
{
_schedule = schedule;
_clock = clock;
_content_revision++; // e.g. evening time changed: redraw
_content_revision++; // e.g. the No WiFi setting changed: redraw
}
void DisplayController::setEffectTime(uint32_t ms)
@@ -58,7 +58,7 @@ bool DisplayController::powerEnabled() const
return _power_enabled;
}
void DisplayController::update(uint32_t now, bool time_valid, int hour, int minute, bool wifi_connected)
void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &time, bool wifi_connected)
{
if (wifi_connected)
_wifi_lost = false;
@@ -106,7 +106,7 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
if (!time_valid)
return;
switch (scheduleFor(hour, minute, _schedule))
switch (scheduleFor(time.hour, time.minute, _schedule))
{
case ScheduleMode::Night:
if (_power.isOn())
@@ -118,8 +118,8 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
case ScheduleMode::Clock:
_off_timer.cancel();
_ensureOn();
if (_page == Page::Clock)
_showClock(hour, minute, show_no_wifi);
if (_page == Page::Dynamic)
_showDynamic(time, show_no_wifi);
else
_showOnce(Screen::Page);
break;
@@ -139,21 +139,20 @@ void DisplayController::_ensureOn()
_screen = Screen::Unknown;
}
void DisplayController::_showClock(int hour, int minute, bool no_wifi)
// Drawn again each minute (time, date and background can change) and when the content changed
void DisplayController::_showDynamic(const LocalTime &time, bool no_wifi)
{
Screen screen = no_wifi ? Screen::NoWifi : Screen::Clock;
bool evening = isEvening(hour, minute, _clock);
if (_screen == screen && _shown_hour == hour && _shown_minute == minute && _shown_evening == evening &&
Screen screen = no_wifi ? Screen::NoWifi : Screen::Dynamic;
if (_screen == screen && _shown_hour == time.hour && _shown_minute == time.minute &&
_shown_revision == _content_revision)
return;
if (no_wifi)
_frames.noWifi(hour, minute);
_frames.noWifi(time.hour, time.minute);
else
_frames.clock(hour, minute, evening);
_frames.dynamicPage(time);
_screen = screen;
_shown_hour = hour;
_shown_minute = minute;
_shown_evening = evening;
_shown_hour = time.hour;
_shown_minute = time.minute;
_shown_revision = _content_revision;
}
+14 -13
View File
@@ -2,6 +2,7 @@
#include <stdint.h>
#include "logic/local_time.h"
#include "logic/power_timer.h"
#include "logic/settings.h"
@@ -19,8 +20,9 @@ public:
class Frames
{
public:
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)
// The dynamic page selected as page, drawn for this time
virtual void dynamicPage(const LocalTime &time) = 0;
// Shown instead of a dynamic page 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;
@@ -32,8 +34,8 @@ public:
enum class Page : uint8_t
{
Clock, // Pikachu with the time
Bitmap, // a bitmap (FlipdotFrames holds which one)
Dynamic, // a dynamic page, redrawn every minute (FlipdotFrames holds which one)
Bitmap, // a bitmap (FlipdotFrames holds which one)
};
// Decides what the display shows and when it is powered, based on the time, the settings,
@@ -49,8 +51,8 @@ public:
// The power stays on that much longer after the last frame.
void setEffectTime(uint32_t ms);
// What is shown during the day. Call pageChanged() when the page or off bitmap
// itself changed.
// What is shown during the day. Call pageChanged() when the page, its content (e.g. a
// Home Assistant value) or the off bitmap changed.
void setPage(Page page);
void pageChanged();
@@ -66,14 +68,14 @@ public:
bool powerEnabled() const;
// Call often (every loop). time_valid is false until the time is synced over NTP.
// While WiFi is down for NO_WIFI_SHOW_TIME, "No WiFi" is shown instead of the clock.
void update(uint32_t now, bool time_valid, int hour, int minute, bool wifi_connected);
// While WiFi is down for NO_WIFI_SHOW_TIME, "No WiFi" is shown instead of a dynamic page.
void update(uint32_t now, bool time_valid, const LocalTime &time, bool wifi_connected);
private:
enum class Screen
{
Unknown, // nothing sent since the last power on
Clock,
Dynamic,
NoWifi,
Page,
Live,
@@ -86,7 +88,7 @@ private:
ScheduleSettings _schedule;
ClockSettings _clock;
Page _page = Page::Clock;
Page _page = Page::Dynamic;
uint32_t _effect_time = 0;
bool _power_enabled = true;
@@ -94,8 +96,7 @@ private:
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 _content_revision = 0; // changed page, its content, bitmap or live image
uint32_t _shown_revision = 0;
bool _live = false;
@@ -105,6 +106,6 @@ private:
uint32_t _wifi_lost_since = 0;
void _ensureOn();
void _showClock(int hour, int minute, bool no_wifi);
void _showDynamic(const LocalTime &time, bool no_wifi);
void _showOnce(Screen screen);
};
+110
View File
@@ -0,0 +1,110 @@
#include "logic/font.h"
#include <string.h>
#include "logic/bitmap_format.h"
static int rowBytes(int width)
{
return (width + 7) / 8;
}
bool validFont(const uint8_t *data, size_t size)
{
if (data == nullptr || size < (size_t)FONT_HEADER_SIZE || size > FONT_MAX_SIZE || memcmp(data, "FDF1", 4) != 0)
return false;
int height = data[4], spacing = data[5], first = data[6], count = data[7];
if (height < 1 || height > FONT_MAX_HEIGHT || spacing > FONT_MAX_SPACING || count == 0 || first + count > 256)
return false;
size_t expected = FONT_HEADER_SIZE + count;
if (size < expected)
return false;
for (int i = 0; i < count; i++)
{
int width = data[FONT_HEADER_SIZE + i];
if (width > FONT_MAX_WIDTH)
return false;
expected += (size_t)height * rowBytes(width);
}
return size == expected;
}
// The width and dots of a character; false when it is not in the font
static bool glyph(const Font &font, unsigned char c, int &width, const uint8_t *&dots)
{
const uint8_t *d = font.data;
int height = d[4], first = d[6], count = d[7];
if (c < first || c >= first + count)
return false;
const uint8_t *p = d + FONT_HEADER_SIZE + count;
for (int i = 0; i < c - first; i++)
p += height * rowBytes(d[FONT_HEADER_SIZE + i]);
width = d[FONT_HEADER_SIZE + c - first];
dots = p;
return width > 0;
}
static bool usable(const Font &font)
{
return font.data && font.size >= (size_t)FONT_HEADER_SIZE && font.scale >= 1;
}
int fontHeight(const Font &font)
{
return usable(font) ? font.data[4] * font.scale : 0;
}
int textWidth(const Font &font, const char *text)
{
if (!usable(font) || text == nullptr)
return 0;
int total = 0, characters = 0;
for (const char *c = text; *c; c++)
{
int width;
const uint8_t *dots;
if (!glyph(font, (unsigned char)*c, width, dots))
continue;
total += width;
characters++;
}
if (characters > 1)
total += (characters - 1) * font.data[5];
return total * font.scale;
}
static void drawDot(uint8_t *bitmap, int x, int y, DrawMode mode)
{
if (x < 0 || y < 0 || x >= BITMAP_WIDTH || y >= BITMAP_HEIGHT)
return;
bool on = mode == DrawMode::Invert ? !bitmapPixel(bitmap, x, y) : true;
setBitmapPixel(bitmap, x, y, on);
}
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode)
{
if (!usable(font) || text == nullptr)
return;
int height = font.data[4], spacing = font.data[5], scale = font.scale;
bool first = true;
for (const char *c = text; *c; c++)
{
int width;
const uint8_t *dots;
if (!glyph(font, (unsigned char)*c, width, dots))
continue;
if (!first)
x += spacing * scale;
first = false;
for (int row = 0; row < height; row++)
for (int col = 0; col < width; col++)
{
if (!(dots[row * rowBytes(width) + col / 8] & (0x80 >> (col & 7))))
continue;
for (int dy = 0; dy < scale; dy++)
for (int dx = 0; dx < scale; dx++)
drawDot(bitmap, x + col * scale + dx, y + row * scale + dy, mode);
}
x += width * scale;
}
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// Dot fonts of the dynamic pages: the built-in ones (logic/builtin_fonts.h) and the ones
// converted from TTF/OTF in the web UI (stored on LittleFS). Pure logic, tested in
// test/test_esp32.
//
// Font file format "FDF1" (the web UI writes the same format):
// 0-3 "FDF1"
// 4 height in dots (1-19)
// 5 spacing: empty columns between two characters (0-4)
// 6 first character code
// 7 number of characters
// 8... the width of each character (0-32, 0 = not in the font: skipped)
// then the dots of each character: `height` rows, (width + 7) / 8 bytes per row, the
// most significant bit is the leftmost dot (like the bitmaps)
const int FONT_HEADER_SIZE = 8;
const int FONT_MAX_HEIGHT = 19;
const int FONT_MAX_WIDTH = 32;
const int FONT_MAX_SPACING = 4;
const size_t FONT_MAX_SIZE = 8192;
struct Font
{
const uint8_t *data = nullptr;
size_t size = 0;
uint8_t scale = 1; // each dot drawn as scale x scale dots ("Large" is "Normal" x 2)
};
// Checks the header and that the size matches the widths
bool validFont(const uint8_t *data, size_t size);
// Height in dots (scaled), 0 for an invalid font
int fontHeight(const Font &font);
// Width of the text in dots (scaled), without spacing after the last character
int textWidth(const Font &font, const char *text);
enum class DrawMode : uint8_t
{
Set, // the dots of the text are switched on
Invert, // the dots of the text flip what is below them
};
// Draws the text with its top left corner at x, y into a full screen bitmap
// (logic/bitmap_format.h). Dots outside the screen are skipped.
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode);
+12
View File
@@ -0,0 +1,12 @@
#pragma once
// The local date and time, as far as the display needs it (from struct tm). Pure logic.
struct LocalTime
{
int year = 2000;
int month = 1; // 1-12
int day = 1; // 1-31
int weekday = 6; // 0 = Sunday
int hour = 0;
int minute = 0;
};
+203
View File
@@ -0,0 +1,203 @@
#include "logic/page.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const uint16_t MINUTES_PER_DAY = 24 * 60;
static bool terminated(const char *text, size_t size)
{
return memchr(text, '\0', size) != nullptr;
}
// Printable ASCII only: the fonts have no other characters
static bool printable(const char *text, size_t size)
{
if (!terminated(text, size))
return false;
for (const char *c = text; *c; c++)
if (*c < 32 || *c > 126)
return false;
return true;
}
bool validEntityId(const char *entity)
{
if (entity == nullptr)
return false;
const char *dot = strchr(entity, '.');
if (dot == nullptr || dot == entity || dot[1] == '\0' || strchr(dot + 1, '.'))
return false;
for (const char *c = entity; *c; c++)
if (c != dot && !islower((unsigned char)*c) && !isdigit((unsigned char)*c) && *c != '_')
return false;
return strlen(entity) < (size_t)ELEMENT_ENTITY_SIZE;
}
static bool validAttribute(const char *attribute)
{
for (const char *c = attribute; *c; c++)
if (!isalnum((unsigned char)*c) && *c != '_')
return false;
return true;
}
const char *validatePage(const DynamicPage &page)
{
if (page.slot_count > PAGE_SLOTS)
return "Too many time slots";
if (page.element_count > PAGE_ELEMENTS)
return "Too many elements";
for (int i = 0; i < page.slot_count; i++)
{
const PageSlot &slot = page.slots[i];
if (slot.start >= MINUTES_PER_DAY)
return "Time slots must start before 24:00";
if (i > 0 && slot.start <= page.slots[i - 1].start)
return "Time slots must be in order, each at a different time";
if (!terminated(slot.bitmap, sizeof(slot.bitmap)) || (slot.bitmap[0] && !validBitmapName(slot.bitmap)))
return "Invalid background bitmap name";
}
for (int i = 0; i < page.element_count; i++)
{
const PageElement &e = page.elements[i];
if (e.type >= ElementType::Count || e.align >= Align::Count || e.overlay >= Overlay::Count ||
e.language >= Language::Count)
return "Unknown element setting";
if (e.x < -BITMAP_WIDTH || e.x > 2 * BITMAP_WIDTH || e.y < -BITMAP_HEIGHT || e.y > BITMAP_HEIGHT)
return "Element position out of range";
if (!terminated(e.font, sizeof(e.font)) || !validBitmapName(e.font))
return "Invalid font name";
if (!printable(e.text, sizeof(e.text)) || !printable(e.suffix, sizeof(e.suffix)))
return "Texts can only contain plain characters (no accents), up to 32";
if (!terminated(e.entity, sizeof(e.entity)) || !terminated(e.attribute, sizeof(e.attribute)) ||
!validAttribute(e.attribute))
return "Invalid entity or attribute";
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
return "Decimals must be 0-4, or as received";
if (e.type == ElementType::Clock && e.format >= CLOCK_FORMAT_COUNT)
return "Unknown clock format";
if (e.type == ElementType::Date && e.format >= DATE_FORMAT_COUNT)
return "Unknown date format";
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
return "Home Assistant values need an entity id like sensor.outside_temperature";
}
return nullptr;
}
const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day)
{
if (page.slot_count == 0)
return nullptr;
const PageSlot *active = &page.slots[page.slot_count - 1];
for (int i = 0; i < page.slot_count; i++)
if (page.slots[i].start <= minute_of_day)
active = &page.slots[i];
return active;
}
// ---------------------------------------------------------------------------
// Texts
// ---------------------------------------------------------------------------
static const char *const WEEKDAYS[][7] = {
{"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"},
{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
};
static const char *const WEEKDAYS_SHORT[][7] = {
{"zo", "ma", "di", "wo", "do", "vr", "za"},
{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"},
};
static const char *const MONTHS[][12] = {
{"januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober",
"november", "december"},
{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
"November", "December"},
};
static const char *const MONTHS_SHORT[][12] = {
{"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"},
{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
};
static void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size)
{
int lang = e.language < Language::Count ? (int)e.language : 0;
int wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0;
int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0;
switch (e.format)
{
case DATE_WEEKDAY_DAY_MONTH:
snprintf(out, size, "%s %d %s", WEEKDAYS_SHORT[lang][wd], t.day, MONTHS_SHORT[lang][mo]);
break;
case DATE_DAY_MONTH:
snprintf(out, size, "%d %s", t.day, MONTHS_SHORT[lang][mo]);
break;
case DATE_DAY_MONTH_LONG:
snprintf(out, size, "%d %s", t.day, MONTHS[lang][mo]);
break;
case DATE_NUMERIC:
snprintf(out, size, "%02d-%02d", t.day, t.month);
break;
case DATE_NUMERIC_YEAR:
snprintf(out, size, "%02d-%02d-%04d", t.day, t.month, t.year);
break;
case DATE_WEEKDAY:
snprintf(out, size, "%s", WEEKDAYS[lang][wd]);
break;
default:
snprintf(out, size, "%s", WEEKDAYS_SHORT[lang][wd]);
break;
}
}
// Rounded to the element's decimals when it is a number; "--" when unknown
static void formatHaValue(const PageElement &e, const char *value, char *out, size_t size)
{
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
value = "--";
char number[24];
if (e.decimals >= 0 && strcmp(value, "--") != 0)
{
char *end;
double v = strtod(value, &end);
if (end != value && *end == '\0')
{
snprintf(number, sizeof(number), "%.*f", e.decimals, v);
if (strcmp(number, "-0") == 0 || strncmp(number, "-0.", 3) == 0)
{
// -0.0 after rounding: no minus sign
bool zero = true;
for (const char *c = number + 1; *c; c++)
if (*c != '0' && *c != '.')
zero = false;
if (zero)
memmove(number, number + 1, strlen(number));
}
value = number;
}
}
snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
}
void elementText(const PageElement &e, const LocalTime &t, const char *ha_value, char *out, size_t size)
{
switch (e.type)
{
case ElementType::Clock:
snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute);
break;
case ElementType::Date:
formatDate(e, t, out, size);
break;
case ElementType::HaValue:
formatHaValue(e, ha_value, out, size);
break;
default:
snprintf(out, size, "%s", e.text);
break;
}
}
+121
View File
@@ -0,0 +1,121 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "logic/bitmap_format.h"
#include "logic/local_time.h"
// Dynamic pages: a background bitmap that depends on the time of day, with elements
// (clock, date, Home Assistant value, text) drawn on top. Stored as a binary struct on
// LittleFS (display/page_store), edited in the web UI. Pure logic, tested in
// test/test_esp32.
const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1;
const int PAGE_SLOTS = 8;
const int PAGE_ELEMENTS = 8;
const int ELEMENT_TEXT_SIZE = 33;
const int ELEMENT_SUFFIX_SIZE = 17;
const int ELEMENT_ENTITY_SIZE = 65;
const int ELEMENT_ATTRIBUTE_SIZE = 33;
enum class ElementType : uint8_t
{
Clock,
Date,
HaValue, // a Home Assistant entity state (or attribute)
Text,
Count,
};
// How an element is drawn over the background
enum class Overlay : uint8_t
{
OnTop, // its dots are switched on, the background stays
Box, // its area (plus one dot around it) is cleared first
Invert, // its dots flip the background
Count,
};
// What x means: the left edge, the middle or the right edge of the element
enum class Align : uint8_t
{
Left,
Center,
Right,
Count,
};
enum class Language : uint8_t
{
Dutch,
English,
Count,
};
// Clock formats
const uint8_t CLOCK_HH_MM = 0; // 08:05
const uint8_t CLOCK_H_MM = 1; // 8:05
const uint8_t CLOCK_FORMAT_COUNT = 2;
// Date formats (Dutch / English examples)
const uint8_t DATE_WEEKDAY_DAY_MONTH = 0; // do 15 okt / Thu 15 Oct
const uint8_t DATE_DAY_MONTH = 1; // 15 okt / 15 Oct
const uint8_t DATE_DAY_MONTH_LONG = 2; // 15 oktober / 15 October
const uint8_t DATE_NUMERIC = 3; // 15-10
const uint8_t DATE_NUMERIC_YEAR = 4; // 15-10-2026
const uint8_t DATE_WEEKDAY = 5; // donderdag / Thursday
const uint8_t DATE_WEEKDAY_SHORT = 6; // do / Thu
const uint8_t DATE_FORMAT_COUNT = 7;
// Home Assistant values: decimals as received
const int8_t DECIMALS_AS_IS = -1;
const int8_t DECIMALS_MAX = 4;
// Background from `start` (minutes since midnight) until the next slot
struct PageSlot
{
uint16_t start = 0;
char bitmap[PAGE_NAME_SIZE] = ""; // empty: blank
};
struct PageElement
{
ElementType type = ElementType::Clock;
int16_t x = 0; // may be partly off screen
int16_t y = 0;
Align align = Align::Left;
Overlay overlay = Overlay::OnTop;
char font[PAGE_NAME_SIZE] = "Normal";
uint8_t format = 0; // CLOCK_* or DATE_*
Language language = Language::Dutch;
int8_t decimals = DECIMALS_AS_IS;
char text[ELEMENT_TEXT_SIZE] = ""; // Text: the text; HaValue: shown before the value
char suffix[ELEMENT_SUFFIX_SIZE] = ""; // HaValue: shown after the value (unit)
char entity[ELEMENT_ENTITY_SIZE] = ""; // HaValue: e.g. sensor.outside_temperature
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // HaValue: empty = the state
};
struct DynamicPage
{
uint8_t slot_count = 0;
PageSlot slots[PAGE_SLOTS];
uint8_t element_count = 0;
PageElement elements[PAGE_ELEMENTS];
};
// Returns nullptr when the page is fine, otherwise a message for the user. Does not check
// that the bitmaps and fonts exist.
const char *validatePage(const DynamicPage &page);
// The slot shown at this time: the last one that started, or the last slot of the day
// before when it is earlier than the first one. nullptr without slots.
const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day);
// The text of an element. ha_value is the entity state (nullptr: not known yet, shown as
// "--"). out_size must be at least 2.
void elementText(const PageElement &element, const LocalTime &time, const char *ha_value, char *out,
size_t out_size);
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.')
bool validEntityId(const char *entity);
+58
View File
@@ -0,0 +1,58 @@
#include "logic/page_render.h"
#include <string.h>
#include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
static Font usableFont(PageResources &resources, const char *name)
{
Font font = resources.font(name);
if (validFont(font.data, font.size) && font.scale >= 1)
return font;
return builtinFont(*findBuiltinFont(FONT_NORMAL_NAME));
}
static void clearBox(uint8_t *bitmap, int x, int y, int width, int height)
{
for (int yy = y; yy < y + height; yy++)
for (int xx = x; xx < x + width; xx++)
setBitmapPixel(bitmap, xx, yy, false);
}
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap,
ElementBox *boxes)
{
memset(bitmap, 0, BITMAP_BYTES);
const PageSlot *slot = activeSlot(page, time.hour * 60 + time.minute);
if (slot && slot->bitmap[0] && !resources.background(slot->bitmap, bitmap))
memset(bitmap, 0, BITMAP_BYTES);
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
{
const PageElement &e = page.elements[i];
const char *ha_value = e.type == ElementType::HaValue ? resources.haValue(e) : nullptr;
char text[64];
elementText(e, time, ha_value, text, sizeof(text));
Font font = usableFont(resources, e.font);
int width = textWidth(font, text), height = fontHeight(font);
int x = e.x;
if (e.align == Align::Center)
x -= width / 2;
else if (e.align == Align::Right)
x -= width;
if (e.overlay == Overlay::Box)
clearBox(bitmap, x - 1, e.y - 1, width + 2, height + 2);
drawText(bitmap, font, x, e.y, text, e.overlay == Overlay::Invert ? DrawMode::Invert : DrawMode::Set);
if (boxes)
{
boxes[i].x = x;
boxes[i].y = e.y;
boxes[i].width = width;
boxes[i].height = height;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <stdint.h>
#include "logic/font.h"
#include "logic/local_time.h"
#include "logic/page.h"
// Draws a dynamic page into a full screen bitmap (logic/bitmap_format.h). Used for the
// display and for the previews in the web UI. Pure logic, tested in test/test_esp32.
// Where the page gets its bitmaps, fonts and Home Assistant values
class PageResources
{
public:
// Loads a background bitmap; false when it is gone (the background is blank then)
virtual bool background(const char *name, uint8_t *bitmap) = 0;
// A font by name; a font without data when it is gone ("Normal" is used then)
virtual Font font(const char *name) = 0;
// The current value of a Home Assistant element; nullptr when not known
virtual const char *haValue(const PageElement &element) = 0;
};
// The area an element covers on the screen (for the editor), may be partly off screen
struct ElementBox
{
int16_t x = 0;
int16_t y = 0;
int16_t width = 0;
int16_t height = 0;
};
// boxes: nullptr, or room for page.element_count boxes
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap,
ElementBox *boxes = nullptr);
-5
View File
@@ -12,8 +12,3 @@ ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &s)
return ScheduleMode::Clock;
return ScheduleMode::Standby;
}
bool isEvening(int hour, int minute, const ClockSettings &s)
{
return s.evening_enabled && hour * 60 + minute >= s.evening_start;
}
+1 -4
View File
@@ -6,7 +6,7 @@
// settings:
//
// 00:00 - 07:59 Night screen cleared, power off
// 08:00 - 21:59 Clock page shown (the clock is updated every minute)
// 08:00 - 21:59 Clock page shown (a dynamic page 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 image stays on the dots
// 23:31 - 23:59 Night
@@ -19,6 +19,3 @@ enum class ScheduleMode
};
ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &settings);
// Evening: the yawning Pikachu is shown
bool isEvening(int hour, int minute, const ClockSettings &settings);
+5 -6
View File
@@ -12,8 +12,7 @@ static bool terminated(const char *text, int size)
const char *validateSettings(const Settings &s)
{
if (!terminated(s.page, sizeof(s.page)) ||
(strcmp(s.page, PAGE_CLOCK) != 0 && !validBitmapName(s.page)))
if (!terminated(s.page, sizeof(s.page)) || !validBitmapName(s.page))
return "Unknown page";
if (!terminated(s.off_page, sizeof(s.off_page)) || (s.off_page[0] && !validBitmapName(s.off_page)))
return "Unknown off image";
@@ -24,9 +23,6 @@ const char *validateSettings(const Settings &s)
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";
@@ -42,10 +38,13 @@ const char *validateSettings(const Settings &s)
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)))
!terminated(ha.password, sizeof(ha.password)) || !terminated(ha.api_url, sizeof(ha.api_url)) ||
!terminated(ha.token, sizeof(ha.token)))
return "Home Assistant text too long";
if (ha.port == 0)
return "Invalid MQTT port";
if (ha.api_url[0] && strncmp(ha.api_url, "http://", 7) != 0)
return "The Home Assistant URL must start with http:// (https is not supported)";
return nullptr;
}
+12 -10
View File
@@ -2,6 +2,9 @@
#include <stdint.h>
#include "logic/builtin_pages.h"
#include "logic/page.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.
//
@@ -18,9 +21,7 @@ struct ScheduleSettings
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
bool no_wifi_screen = true; // "No WiFi" instead of a dynamic page when WiFi is down
};
struct HomeAssistantSettings
@@ -30,6 +31,9 @@ struct HomeAssistantSettings
uint16_t port = 1883;
char user[32] = "";
char password[64] = "";
// Values for the dynamic pages, read over Home Assistant's REST API (net/ha_api)
char api_url[96] = ""; // empty: http://<MQTT broker>:8123
char token[256] = ""; // long-lived access token (HA user profile, Security)
};
// How the Nano drives the dots (besides the flip time). Sent to the Nano, which keeps
@@ -50,25 +54,23 @@ struct DriveSettings
bool effect_small_only = false; // big changes (a new page) appear instantly
};
// What is shown during the day: "clock" or the name of a bitmap
const int PAGE_NAME_SIZE = 25;
#define PAGE_CLOCK "clock"
// What is shown during the day: the name of a dynamic page (logic/page.h) or a bitmap.
// Both share one namespace. When it is gone, the built-in Clock page is shown.
#define DEFAULT_PAGE BUILTIN_PAGE_CLOCK
// What is left on the dots when the display switches off: empty = blank, or a bitmap name
#define OFF_PAGE_BLANK ""
struct Settings
{
char page[PAGE_NAME_SIZE] = PAGE_CLOCK;
char page[PAGE_NAME_SIZE] = DEFAULT_PAGE;
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;
// Added in settings version 2: keep it last, the fields before it are read from
// version 1 records (system/settings_store.cpp)
char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK;
// Added in version 3
DriveSettings drive;
// New fields go here, at the end (see system/settings_store.cpp)
};
// How long a new frame can take to appear on the dots in ms (0 when instant)
+7 -4
View File
@@ -3,13 +3,13 @@
// The objects and the actions on them are in app.cpp; this file only sets the order.
#include <Arduino.h>
#include <time.h>
#include "app.h"
#include "config.h"
#include "display/bitmap_store.h"
#include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h"
#include "net/ha_api.h"
#include "net/home_assistant.h"
#include "net/wifi_ota.h"
#include "system/boot_target.h"
@@ -79,15 +79,18 @@ void loop()
homeAssistantLoop();
webUiLoop();
appLoop();
// Home Assistant values of the shown page (can block about 3 s once a minute)
if (haApiLoop(wifiConnected()))
controller.pageChanged();
// While the Nano is being flashed, nothing else may talk to it
if (flashBridge.handle())
return;
status.loop(); // changed flip settings, once the display is on
struct tm now = {};
bool time_valid = getLocalTime(&now, 0);
controller.update(millis(), time_valid, now.tm_hour, now.tm_min, wifiConnected());
LocalTime now;
bool time_valid = appLocalTime(now);
controller.update(millis(), time_valid, now, wifiConnected());
bool led = appSettings().status_led && statusLedLevel(millis(), wifiConnected(), nano.isOn());
digitalWrite(LED_PIN, led);
+178
View File
@@ -0,0 +1,178 @@
#include "net/ha_api.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <vector>
#include "config.h"
#include "credentials.h"
struct Entry
{
String entity;
String attribute;
String value;
bool known;
bool watched;
uint32_t next_poll;
};
static String base_url;
static String token;
static std::vector<Entry> entries;
// Kept for the editor's previews, besides the watched ones
static const size_t MAX_ENTRIES = 16;
void haApiSetSettings(const HomeAssistantSettings &settings)
{
if (settings.api_url[0])
base_url = settings.api_url;
else
base_url = String("http://") + (settings.host[0] ? String(settings.host) : BROKER_ADDR.toString()) + ":8123";
while (base_url.endsWith("/"))
base_url.remove(base_url.length() - 1);
token = settings.token;
for (Entry &e : entries)
e.next_poll = millis(); // read again with the new settings
}
static Entry *find(const char *entity, const char *attribute)
{
for (Entry &e : entries)
if (e.entity == entity && e.attribute == attribute)
return &e;
return nullptr;
}
static Entry &add(const char *entity, const char *attribute)
{
Entry *e = find(entity, attribute);
if (e)
return *e;
if (entries.size() >= MAX_ENTRIES)
for (auto it = entries.begin(); it != entries.end(); ++it)
if (!it->watched)
{
entries.erase(it);
break;
}
entries.push_back({entity, attribute, "", false, false, millis()});
return entries.back();
}
void haApiWatch(const DynamicPage *page)
{
for (Entry &e : entries)
e.watched = false;
if (!page)
return;
for (int i = 0; i < page->element_count; i++)
{
const PageElement &element = page->elements[i];
if (element.type != ElementType::HaValue || !validEntityId(element.entity))
continue;
Entry &e = add(element.entity, element.attribute);
if (!e.watched)
e.next_poll = millis();
e.watched = true;
}
}
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer)
static const char *fetch(const String &entity, const String &attribute, String &value)
{
if (!token.length())
return "No Home Assistant token set (Home Assistant tab)";
if (!WiFi.isConnected())
return "No WiFi";
HTTPClient http;
http.setConnectTimeout(HA_HTTP_TIMEOUT);
http.setTimeout(HA_HTTP_TIMEOUT);
if (!http.begin(base_url + "/api/states/" + entity))
return "Invalid Home Assistant URL";
http.addHeader("Authorization", "Bearer " + token);
int code = http.GET();
if (code != 200)
{
http.end();
if (code == 401)
return "Home Assistant refused the token";
if (code == 404)
return "Unknown entity";
return code < 0 ? "Home Assistant not reachable" : "Unexpected answer from Home Assistant";
}
JsonDocument filter;
if (attribute.length())
filter["attributes"][attribute] = true;
else
filter["state"] = true;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString(), DeserializationOption::Filter(filter));
http.end();
if (error)
return "Invalid answer from Home Assistant";
JsonVariantConst v = doc["state"];
if (attribute.length())
v = doc["attributes"][attribute];
if (v.isNull())
return "Unknown attribute";
if (v.is<const char *>())
value = v.as<const char *>();
else
{
value = "";
serializeJson(v, value); // numbers, true/false
}
return nullptr;
}
bool haApiLoop(bool wifi_connected)
{
if (!wifi_connected || !token.length())
return false;
uint32_t now = millis();
for (Entry &e : entries)
{
if (!e.watched || (int32_t)(now - e.next_poll) < 0)
continue;
e.next_poll = now + HA_POLL_INTERVAL;
String value;
const char *error = fetch(e.entity, e.attribute, value);
if (error)
{
// Shown as "--" until it can be read again
Serial.printf("Home Assistant %s: %s\n", e.entity.c_str(), error);
bool changed = e.known;
e.known = false;
return changed;
}
bool changed = !e.known || e.value != value;
e.value = value;
e.known = true;
return changed; // one entity per loop()
}
return false;
}
const char *haApiValue(const char *entity, const char *attribute)
{
Entry *e = find(entity, attribute);
return e && e->known ? e->value.c_str() : nullptr;
}
const char *haApiFetchNow(const char *entity, const char *attribute, String &value)
{
if (!validEntityId(entity))
return "Invalid entity id";
const char *error = fetch(entity, attribute, value);
if (error)
return error;
Entry &e = add(entity, attribute);
e.value = value;
e.known = true;
return nullptr;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include "logic/page.h"
#include "logic/settings.h"
// Values of Home Assistant entities for the dynamic pages, read over Home Assistant's REST
// API (GET <url>/api/states/<entity> with a long-lived access token). Only the entities of
// the shown page are polled, every HA_POLL_INTERVAL, one per loop() so loop() never blocks
// longer than about 2 x HA_HTTP_TIMEOUT. Plain http only.
// URL and token; empty URL: http://<MQTT broker>:8123
void haApiSetSettings(const HomeAssistantSettings &settings);
// The page whose entities are polled (nullptr: none). They are read right away.
void haApiWatch(const DynamicPage *page);
// Polls one entity that is due. Returns true when a watched value changed (redraw).
bool haApiLoop(bool wifi_connected);
// The last value read for an entity (attribute empty: the state); nullptr when unknown
const char *haApiValue(const char *entity, const char *attribute);
// Reads one value now (the web UI's test button) and keeps it. Returns nullptr or an error.
const char *haApiFetchNow(const char *entity, const char *attribute, String &value);
+56 -36
View File
@@ -12,6 +12,7 @@
#include "config.h"
#include "credentials.h"
#include "display/bitmap_store.h"
#include "display/page_store.h"
#include "net/ha_page_select.h"
#include "system/boot_target.h"
@@ -68,35 +69,51 @@ static void onPowerCommand(bool state, HASwitch *sender)
sender->setState(state);
}
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
// alphabetically. Rebuilt when bitmaps were added or deleted.
static const std::vector<String> &bitmapNames()
static bool lessName(const String &a, const String &b)
{
static std::vector<String> names;
static bool loaded = false;
static uint32_t revision = 0;
if (loaded && revision == bitmapListRevision())
return names;
loaded = true;
revision = bitmapListRevision();
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
std::vector<String> uploaded;
names.clear();
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
// alphabetically
static std::vector<String> bitmapNames()
{
std::vector<String> names, uploaded;
for (const BitmapInfo &b : listBitmaps())
(b.builtin ? names : uploaded).push_back(b.name);
std::sort(uploaded.begin(), uploaded.end(),
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
std::sort(uploaded.begin(), uploaded.end(), lessName);
names.insert(names.end(), uploaded.begin(), uploaded.end());
return names;
}
// A dropdown with one fixed option (the clock, or blank) followed by the bitmaps, for a
// setting that holds either the fixed value or a bitmap name
struct BitmapChoice
// The dynamic pages the same way, followed by the bitmaps
static std::vector<String> pageNames()
{
std::vector<String> names, stored;
for (const PageInfo &p : listPages())
(p.builtin ? names : stored).push_back(p.name);
std::sort(stored.begin(), stored.end(), lessName);
names.insert(names.end(), stored.begin(), stored.end());
std::vector<String> bitmaps = bitmapNames();
names.insert(names.end(), bitmaps.begin(), bitmaps.end());
return names;
}
// Changes when bitmaps or pages were added or deleted
static uint32_t listRevision()
{
return bitmapListRevision() + pageListRevision();
}
// A dropdown for a setting that holds a name from the list, or optionally a fixed value
// shown as an extra first option (the blank off image)
struct NameChoice
{
HAPageSelect &select;
const char *fixed_option; // shown in Home Assistant
const char *fixed_option; // shown in Home Assistant; nullptr: no fixed option
const char *fixed_value; // stored in the settings
const char *fallback; // shown when the setting's name is gone (what the display shows then)
std::vector<String> (*names)();
const char *(*setting)();
const char *(*choose)(const char *value); // returns nullptr or an error
@@ -106,28 +123,29 @@ struct BitmapChoice
char state_for[PAGE_NAME_SIZE]; // setting the state was published for
std::vector<String> options;
// The option for the setting; the fixed one when the bitmap is gone (the display
// falls back to the clock or blank then too)
const char *current()
{
const char *value = setting();
if (strcmp(value, fixed_value) != 0)
for (const String &option : options)
if (strcasecmp(option.c_str(), value) == 0)
return option.c_str();
return fixed_option;
if (fixed_option && strcmp(value, fixed_value) == 0)
return fixed_option;
for (const String &option : options)
if (strcasecmp(option.c_str(), value) == 0)
return option.c_str();
return fallback;
}
// Rebuilds the options when bitmaps were added or deleted and reports the setting
// Rebuilds the options when bitmaps or pages were added or deleted and reports the setting
void update()
{
if (!options_loaded || options_revision != bitmapListRevision())
if (!options_loaded || options_revision != listRevision())
{
const std::vector<String> &names = bitmapNames();
options_loaded = true;
options_revision = bitmapListRevision();
options.assign(1, fixed_option);
options.insert(options.end(), names.begin(), names.end());
options_revision = listRevision();
options.clear();
if (fixed_option)
options.push_back(fixed_option);
std::vector<String> list = names();
options.insert(options.end(), list.begin(), list.end());
select.setOptions(options);
state_known = false;
}
@@ -142,17 +160,19 @@ struct BitmapChoice
void command(const char *option)
{
const char *error = choose(strcmp(option, fixed_option) == 0 ? fixed_value : option);
const char *error = choose(fixed_option && strcmp(option, fixed_option) == 0 ? fixed_value : option);
if (error)
Serial.printf("Home Assistant \"%s\": %s\n", option, error);
update(); // report the (new) state back
}
};
// Bitmaps can't be called "clock", and the blank off image is stored as ""
static BitmapChoice mode = {modeSelect, "Clock", PAGE_CLOCK, []() { return appSettings().page; }, appSelectPage};
static BitmapChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, []() { return appSettings().off_page; },
appSelectOffPage};
// "Clock" is the built-in page (it was a fixed option before, so automations keep working)
static NameChoice mode = {modeSelect, nullptr, nullptr, DEFAULT_PAGE, pageNames,
[]() { return appSettings().page; }, appSelectPage};
// The blank off image is stored as ""
static NameChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, "Blank", bitmapNames,
[]() { return appSettings().off_page; }, appSelectOffPage};
static void clearRemovedEntities()
{
+2 -1
View File
@@ -5,7 +5,8 @@
// Home Assistant integration over MQTT (with discovery):
// FlipDot Display Power switch: off = draw the off image and switch the display off
// FlipDot Display Mode select: the page shown during the day ("Clock" or a bitmap)
// FlipDot Display Mode select: the page shown during the day (a dynamic page, e.g.
// the built-in "Clock", or a bitmap)
// FlipDot Display Off image select: left on the dots when the display switches off
// ("Blank" or a bitmap)
// FlipDot Display IP sensor: the IP address
+81 -12
View File
@@ -7,21 +7,77 @@
static const char *NAMESPACE = "flipdot";
static const char *KEY = "settings";
// Increase when the Settings struct changes; old records are then ignored (defaults),
// unless OLD_VERSIONS says how to read them
static const uint16_t VERSION = 3;
// unless the code below says how to read them. When fields are only added at the end,
// an old record is the start of the new layout: add an OldVersion for it like v3 does.
static const uint16_t VERSION = 4;
// Older versions: fields were only added at the end, so the start of an old record has
// the same layout. New fields keep their defaults.
struct OldVersion
{
uint16_t version;
size_t fields; // bytes of Settings that version had
size_t fields; // bytes of the settings struct that version had
};
static const OldVersion OLD_VERSIONS[] = {
// Versions 1-3: the layout before the dynamic pages (version 4 removed the evening
// settings of the clock and added the Home Assistant API). Frozen copy, read as a prefix
// (version 1 lacked off_page and drive, version 2 drive) and converted.
namespace v3
{
struct ClockSettings
{
bool evening_enabled = true;
uint16_t evening_start = 17 * 60;
bool no_wifi_screen = true;
};
struct HomeAssistantSettings
{
bool enabled = true;
char host[64] = "";
uint16_t port = 1883;
char user[32] = "";
char password[64] = "";
};
struct Settings
{
char page[25] = "clock";
ScheduleSettings schedule;
ClockSettings clock;
HomeAssistantSettings home_assistant;
uint16_t flip_time = 225;
bool status_led = true;
char off_page[25] = "";
DriveSettings drive;
};
static const OldVersion VERSIONS[] = {
{1, offsetof(Settings, off_page)},
{2, offsetof(Settings, drive)},
{3, sizeof(Settings)},
};
static void convert(const Settings &old, ::Settings &s)
{
s = ::Settings();
memcpy(s.page, old.page, sizeof(s.page));
s.schedule = old.schedule;
s.clock.no_wifi_screen = old.clock.no_wifi_screen;
const HomeAssistantSettings &ha = old.home_assistant;
s.home_assistant.enabled = ha.enabled;
memcpy(s.home_assistant.host, ha.host, sizeof(ha.host));
s.home_assistant.port = ha.port;
memcpy(s.home_assistant.user, ha.user, sizeof(ha.user));
memcpy(s.home_assistant.password, ha.password, sizeof(ha.password));
s.flip_time = old.flip_time;
s.status_led = old.status_led;
memcpy(s.off_page, old.off_page, sizeof(s.off_page));
s.drive = old.drive;
// The evening (yawning Pikachu from 17:00) is now a time slot of the built-in Clock page
}
} // namespace v3
static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer");
struct StoredSettings
{
uint16_t version;
@@ -29,6 +85,16 @@ struct StoredSettings
Settings settings;
};
// Reads a record whose first `fields` bytes have the layout of T
template <typename T> static bool readOld(const StoredSettings &stored, size_t read, size_t fields, T &out)
{
if (stored.size < fields || stored.size > sizeof(T) || read != offsetof(StoredSettings, settings) + stored.size)
return false;
out = T();
memcpy((void *)&out, &stored.settings, fields);
return true;
}
void loadSettings(Settings &settings)
{
settings = Settings();
@@ -44,14 +110,17 @@ void loadSettings(Settings &settings)
loaded = stored.settings;
else
{
const OldVersion *old = nullptr;
for (const OldVersion &o : OLD_VERSIONS)
bool ok = false;
for (const OldVersion &o : v3::VERSIONS)
if (o.version == stored.version)
old = &o;
if (!old || stored.size < old->fields || stored.size > sizeof(Settings) ||
read != offsetof(StoredSettings, settings) + stored.size)
{
v3::Settings old;
ok = readOld(stored, read, o.fields, old);
if (ok)
v3::convert(old, loaded);
}
if (!ok)
return;
memcpy((void *)&loaded, &stored.settings, old->fields);
}
if (validateSettings(loaded) == nullptr)
settings = loaded;
+104
View File
@@ -0,0 +1,104 @@
#include "web/page_json.h"
#include <Arduino.h>
#include <string.h>
#include "logic/settings.h"
static const char *const TYPES[] = {"clock", "date", "ha", "text"};
static const char *const ALIGNS[] = {"left", "center", "right"};
static const char *const OVERLAYS[] = {"top", "box", "invert"};
static const char *const LANGUAGES[] = {"nl", "en"};
template <size_t N> static int indexOf(const char *const (&names)[N], const char *name)
{
for (size_t i = 0; i < N; i++)
if (name && strcmp(names[i], name) == 0)
return i;
return -1;
}
void pageToJson(const DynamicPage &page, JsonObject out)
{
char time[6];
JsonArray slots = out["slots"].to<JsonArray>();
for (int i = 0; i < page.slot_count; i++)
{
JsonObject slot = slots.add<JsonObject>();
formatTime(page.slots[i].start, time);
slot["start"] = String(time);
slot["bitmap"] = page.slots[i].bitmap;
}
JsonArray elements = out["elements"].to<JsonArray>();
for (int i = 0; i < page.element_count; i++)
{
const PageElement &e = page.elements[i];
JsonObject o = elements.add<JsonObject>();
o["type"] = TYPES[(int)e.type];
o["x"] = e.x;
o["y"] = e.y;
o["align"] = ALIGNS[(int)e.align];
o["overlay"] = OVERLAYS[(int)e.overlay];
o["font"] = e.font;
o["format"] = e.format;
o["language"] = LANGUAGES[(int)e.language];
o["decimals"] = e.decimals;
o["text"] = e.text;
o["suffix"] = e.suffix;
o["entity"] = e.entity;
o["attribute"] = e.attribute;
}
}
// False when the text does not fit
static bool copyText(JsonVariantConst value, char *out, size_t size)
{
const char *text = value | "";
if (strlen(text) >= size)
return false;
strcpy(out, text);
return true;
}
const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
{
page = DynamicPage();
JsonArrayConst slots = in["slots"];
JsonArrayConst elements = in["elements"];
if (slots.size() > (size_t)PAGE_SLOTS)
return "Too many time slots (8 at most)";
if (elements.size() > (size_t)PAGE_ELEMENTS)
return "Too many elements (8 at most)";
for (JsonVariantConst s : slots)
{
PageSlot &slot = page.slots[page.slot_count++];
if (!parseTime(s["start"] | "", slot.start))
return "Times must look like 08:30";
if (!copyText(s["bitmap"], slot.bitmap, sizeof(slot.bitmap)))
return "Bitmap name too long";
}
for (JsonVariantConst o : elements)
{
PageElement &e = page.elements[page.element_count++];
int type = indexOf(TYPES, o["type"]), align = indexOf(ALIGNS, o["align"] | "left"),
overlay = indexOf(OVERLAYS, o["overlay"] | "top"), language = indexOf(LANGUAGES, o["language"] | "nl");
if (type < 0 || align < 0 || overlay < 0 || language < 0)
return "Unknown element setting";
e.type = (ElementType)type;
e.align = (Align)align;
e.overlay = (Overlay)overlay;
e.language = (Language)language;
// Range checks are in validatePage(); out of range numbers must not wrap
e.x = constrain(o["x"] | 0, -1000, 1000);
e.y = constrain(o["y"] | 0, -1000, 1000);
e.format = constrain(o["format"] | 0, 0, 255);
e.decimals = constrain(o["decimals"] | (int)DECIMALS_AS_IS, -100, 100);
if (!copyText(o["font"], e.font, sizeof(e.font)) || !copyText(o["text"], e.text, sizeof(e.text)) ||
!copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
return "Text too long (32 characters, unit 16, entity 64)";
}
return validatePage(page);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <ArduinoJson.h>
#include "logic/page.h"
// Dynamic pages (logic/page.h) as JSON for the web UI:
// {slots: [{start: "08:00", bitmap: "Pikachu"}],
// elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right",
// overlay: "top"|"box"|"invert", font, format, language: "nl"|"en",
// decimals (-1 = as received), text, suffix, entity, attribute}]}
void pageToJson(const DynamicPage &page, JsonObject out);
// Returns nullptr or a message for the user. Validates the page (validatePage()).
const char *pageFromJson(JsonVariantConst in, DynamicPage &page);
+180 -40
View File
@@ -2,14 +2,16 @@
#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.
// The web UI: one page, the browser does the image work (conversion, editor, font
// conversion). Dynamic pages are drawn by the ESP32 (/api/pages/preview), so the preview
// is exactly what the display shows. API: see web/web_ui.h. 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}
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b;--sel:#4da3ff}
*{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}
@@ -21,19 +23,21 @@ nav button{background:var(--card);border:1px solid var(--line);color:var(--text)
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}
h2{font-size:15px;margin:0 0 10px}h3{font-size:13px;margin:14px 0 6px;color:var(--dim)}
.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}
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px;max-width:100%}
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}
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
#f_canvas{width:100%;background:#000;border-radius:8px}
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
.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>
@@ -50,6 +54,37 @@ button.d{color:var(--bad)}
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</p></div></section>
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
<p class="hint">A background per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.</p></div>
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
<label>Preview at <input type="time" id="pe_time"></label></div>
<canvas id="pe_canvas" width="840" height="190"></canvas>
<p class="hint" id="pe_err"></p>
<h3>Background</h3><div id="pe_slots"></div>
<div class="row"><button class="s" onclick="peAddSlot()">Add time slot</button></div>
<p class="hint">Each background is shown from its time until the next one; the last one continues after midnight.</p>
<h3>Elements</h3><div id="pe_elems"></div>
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button></div>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab.</p></div>
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
<h3>Add a font</h3>
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
<div class="row" id="f_opts" style="display:none">
<label>Height <input type="number" id="f_h" min="4" max="19" value="10" style="width:4.5em"> dots</label>
<label>Size <input type="number" id="f_size" min="4" max="80" value="12" style="width:4.5em"> px</label>
<label>Baseline <input type="number" id="f_base" min="1" max="19" value="8" style="width:4.5em"></label>
<label>Spacing <input type="number" id="f_sp" min="0" max="4" value="1" style="width:4em"></label>
<label>Threshold <input type="range" id="f_thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="f_mono" checked> equal width digits</label></div>
<div id="f_prev" style="display:none"><canvas id="f_canvas" width="840" height="190"></canvas>
<div class="row"><input id="f_sample" value="12:34 do 15 okt" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24">
<button class="b" onclick="fontSave()">Save font</button></div></div>
<p class="hint">Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).</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>
@@ -74,10 +109,9 @@ button.d{color:var(--bad)}
<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>Without WiFi</h2><div class="form">
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" instead of a dynamic page when WiFi is down</label>
</div><p class="hint">The backgrounds per time of day (like the yawning Pikachu) are set per page in the Pages tab.</p></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>Flip gap</label><label><input type="number" id="h_gap" min="0" max="5000" style="width:6em"> &micro;s pause after each pulse (0-5000)</label>
@@ -104,8 +138,12 @@ button.d{color:var(--bad)}
<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: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p>
<button class="b" onclick="saveHa()">Save and restart</button></div></section>
</div><p class="hint">Changing these restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p></div>
<div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form">
<label>URL</label><input id="ha_url" placeholder="empty: http://&lt;broker&gt;:8123">
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute.</p></div>
<button class="b" onclick="saveHa()">Save</button></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>
@@ -114,21 +152,22 @@ button.d{color:var(--bad)}
</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']];
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[];
const tabs=[['show','Show'],['pages','Pages'],['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 tab(id){if(!$('t-'+id))id='show';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||{});
const same=(a,b)=>!!a&&!!b&&a.toLowerCase()==b.toLowerCase();
function mk(tag,props,...kids){const e=document.createElement(tag);Object.assign(e,props||{});e.append(...kids);return e}
// 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){}
@@ -141,28 +180,128 @@ function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Las
['HA power switch',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'),offs=$('offpages'),bms=$('bitmaps');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';
// pages, bitmaps and fonts
async function loadLists(){const[b,p,f]=await Promise.all([api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';dyn.innerHTML='';
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
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));
offs.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectOff(bm.name),settings&&settings.off_page==bm.name));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=document.createElement('div');r.className='row';
for(const pg of pageList){const tag=pg.builtin?'built-in page':'page';
pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name)));
const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false)));
if(!pg.builtin)r.append(btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
pages.appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
offs.appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{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)}}
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}
const fl=$('fontlist');fl.innerHTML='';
for(const f of fontList)fl.append(mk('label',{textContent:f.name}),mk('span',{},f.height+' dots high'+(f.builtin?', built-in ':' '),f.builtin?'':btn('Delete',()=>delFont(f.name),'d')));
if(pe)peBuild()}
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 selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadBitmaps()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadBitmaps()}
function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits));
d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
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')}
// page editor: the ESP32 draws the preview and returns the element boxes
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text'};
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:''}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
function peClose(){pe=null;$('pe').style.display='none'}
function peBuild(){peSlots();peElems()}
function field(label,input){return mk('label',{},label+' ',input)}
function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name]));
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',opts),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
function peElems(){const d=$('pe_elems');d.innerHTML='';
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
if(e.type=='clock')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05']]))));
if(e.type=='date')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems))));
if(e.type=='ha'){rows.push(mk('div',{className:'row'},field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e))));
rows.push(mk('div',{className:'row'},field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']]))))}
if(e.type=='text')rows.push(mk('div',{className:'row'},field('Text',inp(e,'text','text',{size:24,maxLength:32}))));
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
rows.push(mk('div',{className:'row'},field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'})),
field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),
field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]))));
box.append(...rows);d.append(box)})}
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
async function peRender(){if(!pe)return;const seq=++peSeq,pos=pe.page.elements.map(e=>[e.x,e.y]),body={page:pePage()};if($('pe_time').value)body.time=$('pe_time').value;
let r,j;try{r=await fetch('/api/pages/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});j=await r.json()}catch(e){j={error:'No connection'}}
if(!pe||seq!=peSeq)return;
if(!r||!r.ok){$('pe_err').textContent=j.error||'Error';$('pe_err').style.color='var(--bad)';return}
$('pe_err').textContent='';peData=fromHex(j.data);pe.boxes=j.boxes;pe.pos=pos;peDraw()}
// the boxes are moved by what changed since the preview was drawn (while dragging)
function peDraw(){if(!pe)return;const c=$('pe_canvas'),g=c.getContext('2d'),s=c.width/W;draw(c,peData||new Uint8Array(W*H));g.lineWidth=2;
pe.boxes.forEach((b,i)=>{const e=pe.page.elements[i],p=pe.pos[i];if(!e||!p)return;const x=b[0]+e.x-p[0],y=b[1]+e.y-p[1];
g.strokeStyle=i==pe.sel?'#4da3ff':'rgba(255,255,255,.35)';g.setLineDash(i==pe.sel?[]:[6,4]);g.strokeRect(x*s-2,y*s-2,Math.max(b[2],1)*s+4,Math.max(b[3],1)*s+4)});g.setLineDash([])}
const pc=$('pe_canvas');
function pcPos(e){const r=pc.getBoundingClientRect();return[(e.clientX-r.left)/r.width*W,(e.clientY-r.top)/r.height*H]}
pc.onpointerdown=e=>{if(!pe)return;const[x,y]=pcPos(e);let hit=-1;
pe.boxes.forEach((b,i)=>{const el=pe.page.elements[i],p=pe.pos[i];if(!el||!p)return;const bx=b[0]+el.x-p[0],by=b[1]+el.y-p[1];
if(x>=bx-1&&x<=bx+b[2]+1&&y>=by-1&&y<=by+b[3]+1)hit=i});
pe.sel=hit;peMark();if(hit<0)return;const el=pe.page.elements[hit];peDrag={i:hit,x0:x,y0:y,ex:el.x,ey:el.y};pc.setPointerCapture(e.pointerId)};
pc.onpointermove=e=>{if(!peDrag)return;const[x,y]=pcPos(e),el=pe.page.elements[peDrag.i],nx=peDrag.ex+Math.round(x-peDrag.x0),ny=peDrag.ey+Math.round(y-peDrag.y0);
if(nx==el.x&&ny==el.y)return;el.x=nx;el.y=ny;peDraw();pePreview()};
pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()};
document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];
if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()});
async function haTest(e){const r=await api('/api/ha/value?entity='+encodeURIComponent(e.entity)+'&attribute='+encodeURIComponent(e.attribute));msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+r.value);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
async function peLive(){if(peData)await live(peData)}
// fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h)
let fontFam=null,fontData=null;
$('f_file').onchange=async e=>{const f=e.target.files[0];if(!f)return;
try{const ff=new FontFace('upload'+Date.now(),await f.arrayBuffer());await ff.load();document.fonts.add(ff);fontFam=ff.family}catch(err){msg('Could not read this font',true);return}
if(!$('f_name').value)$('f_name').value=f.name.replace(/\.[^.]*$/,'').replace(/[^A-Za-z0-9 _-]/g,'').trim().slice(0,24);
$('f_opts').style.display='flex';$('f_prev').style.display='block';fontConvert()};
['f_size','f_base','f_thr','f_sp','f_mono'].forEach(id=>$(id).oninput=fontConvert);$('f_sample').oninput=()=>fontData&&fontPreview();
$('f_h').oninput=()=>{const h=+$('f_h').value;$('f_base').value=Math.round(h*0.8);$('f_size').value=Math.round(h*1.25);fontConvert()};
function fontConvert(){if(!fontFam)return;const h=Math.min(19,Math.max(4,Math.round(+$('f_h').value||10))),size=+$('f_size').value||h,base=+$('f_base').value||h,thr=+$('f_thr').value;
const c=document.createElement('canvas');c.width=Math.ceil(size*2)+16;c.height=h;const g=c.getContext('2d',{willReadFrequently:true});g.font=size+'px "'+fontFam+'"';g.textBaseline='alphabetic';
const glyphs=[];
for(let k=32;k<127;k++){const ch=String.fromCharCode(k);g.clearRect(0,0,c.width,h);g.fillStyle='#fff';g.fillText(ch,8,base);
const d=g.getImageData(0,0,c.width,h).data,on=(x,y)=>d[(y*c.width+x)*4+3]>thr;let x0=c.width,x1=-1;
for(let x=0;x<c.width;x++)for(let y=0;y<h;y++)if(on(x,y)){x0=Math.min(x0,x);x1=Math.max(x1,x)}
let w=x1<0?0:Math.min(32,x1-x0+1);const blank=k==32;if(blank)w=Math.min(32,Math.max(1,Math.round(g.measureText(' ').width)));
const rows=[];for(let y=0;y<h;y++){const r=[];for(let x=0;x<w;x++)r.push(!blank&&on(x0+x,y)?1:0);rows.push(r)}glyphs.push({w,rows})}
if($('f_mono').checked){const dg=glyphs.slice(16,26),mw=Math.max(...dg.map(x=>x.w));
dg.forEach(x=>{const l=Math.floor((mw-x.w)/2),r=mw-x.w-l;x.rows=x.rows.map(row=>Array(l).fill(0).concat(row,Array(r).fill(0)));x.w=mw})}
const sp=Math.min(4,Math.max(0,Math.round(+$('f_sp').value||0))),bytes=[70,68,70,49,h,sp,32,95].concat(glyphs.map(x=>x.w));
glyphs.forEach(x=>x.rows.forEach(row=>{for(let i=0;i<x.w;i+=8){let v=0;for(let b=0;b<8;b++)if(row[i+b])v|=0x80>>b;bytes.push(v)}}));
fontData={bytes,glyphs,h,sp};fontPreview()}
function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(const ch of $('f_sample').value){const k=ch.charCodeAt(0)-32;if(k<0||k>94)continue;const gl=fontData.glyphs[k];if(!gl.w)continue;if(!first)x+=fontData.sp;first=false;
for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w}
draw($('f_canvas'),b)}
async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists()}
// 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;
@@ -175,7 +314,7 @@ 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()}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
// 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);
@@ -198,20 +337,21 @@ async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(
async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()}
// 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;
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;
$('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'}
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_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.clock={no_wifi_screen:$('c_nw').checked};
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').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}
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();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()}
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadLists()}
start();
</script></body></html>)rawliteral";
+221 -22
View File
@@ -4,14 +4,21 @@
#include <WebServer.h>
#include <WiFi.h>
#include <time.h>
#include <vector>
#include "app.h"
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "logic/bitmap_format.h"
#include "logic/page_render.h"
#include "net/ha_api.h"
#include "net/home_assistant.h"
#include "system/boot_target.h"
#include "system/firmware_guard.h"
#include "web/page_json.h"
#include "web/web_page.h"
static WebServer server(80);
@@ -70,6 +77,54 @@ static bool copyTime(JsonVariantConst value, uint16_t &out)
return parseTime(value.as<const char *>(), out);
}
static void sendResult(const char *error)
{
if (error)
sendError(400, error);
else
sendOk();
}
static int hexDigit(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;
}
// Any even number of hex digits, at most max_size bytes
static bool fromHex(const char *hex, std::vector<uint8_t> &out, size_t max_size)
{
size_t length = strlen(hex);
if (length % 2 || length / 2 > max_size)
return false;
out.resize(length / 2);
for (size_t i = 0; i < out.size(); i++)
{
int high = hexDigit(hex[2 * i]), low = hexDigit(hex[2 * i + 1]);
if (high < 0 || low < 0)
return false;
out[i] = high << 4 | low;
}
return true;
}
// The time pages are drawn for in previews: now, or 12:34 before the time is synced
static LocalTime previewTime()
{
LocalTime time;
if (!appLocalTime(time))
{
time.hour = 12;
time.minute = 34;
}
return time;
}
// ---------------------------------------------------------------------------
// Page and status
// ---------------------------------------------------------------------------
@@ -195,14 +250,7 @@ static void handleBitmapSave()
sendError(400, "Invalid bitmap data");
return;
}
const char *error = saveBitmap(name, bitmap);
if (error)
{
sendError(400, error);
return;
}
appBitmapChanged(name);
sendOk();
sendResult(appSaveBitmap(name, bitmap));
}
static void handleBitmapDelete()
@@ -210,18 +258,162 @@ static void handleBitmapDelete()
JsonDocument body;
if (!readBody(body))
return;
const char *name = body["name"] | "";
const char *error = deleteBitmap(name);
sendResult(appDeleteBitmap(body["name"] | ""));
}
// ---------------------------------------------------------------------------
// Dynamic pages
// ---------------------------------------------------------------------------
// Streamed like the bitmaps, each with a preview drawn for the current time
static void handlePages()
{
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
server.sendContent("{\"pages\":[");
bool first = true;
DynamicPage *page = new DynamicPage;
LocalTime time = previewTime();
uint8_t bitmap[BITMAP_BYTES];
char hex[BITMAP_HEX_LENGTH + 1];
for (const PageInfo &info : listPages())
{
if (!loadPage(info.name.c_str(), *page))
continue;
renderPage(*page, time, pageResources, bitmap);
bitmapToHex(bitmap, hex);
JsonDocument doc;
doc["name"] = info.name;
doc["builtin"] = info.builtin;
doc["data"] = hex;
pageToJson(*page, doc["page"].to<JsonObject>());
String item = first ? "" : ",";
serializeJson(doc, item);
server.sendContent(item);
first = false;
}
delete page;
server.sendContent("]}");
server.sendContent("");
}
static void handlePageSave()
{
JsonDocument body;
if (!readBody(body))
return;
DynamicPage *page = new DynamicPage;
const char *error = pageFromJson(body["page"], *page);
if (!error)
error = appSavePage(body["name"] | "", *page);
delete page;
sendResult(error);
}
static void handlePageDelete()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appDeletePage(body["name"] | ""));
}
// Draws a page from the editor: {page, time (optional "HH:MM")} -> {data, boxes: [[x, y, w, h]]}
static void handlePagePreview()
{
JsonDocument body;
if (!readBody(body))
return;
DynamicPage *page = new DynamicPage;
const char *error = pageFromJson(body["page"], *page);
LocalTime time = previewTime();
uint16_t minutes;
if (!error && body["time"].is<const char *>() && parseTime(body["time"], minutes))
{
time.hour = minutes / 60 % 24;
time.minute = minutes % 60;
}
if (error)
{
delete page;
sendError(400, error);
return;
}
uint8_t bitmap[BITMAP_BYTES];
ElementBox boxes[PAGE_ELEMENTS];
renderPage(*page, time, pageResources, bitmap, boxes);
char hex[BITMAP_HEX_LENGTH + 1];
bitmapToHex(bitmap, hex);
JsonDocument doc;
doc["data"] = hex;
JsonArray list = doc["boxes"].to<JsonArray>();
for (int i = 0; i < page->element_count; i++)
{
JsonArray box = list.add<JsonArray>();
box.add(boxes[i].x);
box.add(boxes[i].y);
box.add(boxes[i].width);
box.add(boxes[i].height);
}
delete page;
sendJson(200, doc);
}
// ---------------------------------------------------------------------------
// Fonts and Home Assistant values
// ---------------------------------------------------------------------------
static void handleFonts()
{
JsonDocument doc;
JsonArray list = doc["fonts"].to<JsonArray>();
for (const FontInfo &info : listFonts())
{
JsonObject font = list.add<JsonObject>();
font["name"] = info.name;
font["builtin"] = info.builtin;
font["height"] = info.height;
}
sendJson(200, doc);
}
static void handleFontSave()
{
JsonDocument body;
if (!readBody(body))
return;
std::vector<uint8_t> data;
if (!fromHex(body["data"] | "", data, FONT_MAX_SIZE))
{
sendError(400, "Invalid font data");
return;
}
sendResult(appSaveFont(body["name"] | "", data.data(), data.size()));
}
static void handleFontDelete()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appDeleteFont(body["name"] | ""));
}
// Reads a value now (test button in the page editor); it is then also used in previews
static void handleHaValue()
{
String value;
const char *error = haApiFetchNow(server.arg("entity").c_str(), server.arg("attribute").c_str(), value);
if (error)
{
sendError(400, error);
return;
}
if (strcmp(appSettings().page, name) == 0)
appSelectPage(PAGE_CLOCK);
if (strcmp(appSettings().off_page, name) == 0)
appSelectOffPage(OFF_PAGE_BLANK);
sendOk();
JsonDocument doc;
doc["value"] = value;
sendJson(200, doc);
}
// ---------------------------------------------------------------------------
@@ -296,9 +488,6 @@ static void settingsToJson(const Settings &s, JsonObject o)
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>();
@@ -307,6 +496,8 @@ static void settingsToJson(const Settings &s, JsonObject o)
ha["port"] = s.home_assistant.port;
ha["user"] = s.home_assistant.user;
ha["password_set"] = s.home_assistant.password[0] != '\0';
ha["api_url"] = s.home_assistant.api_url;
ha["token_set"] = s.home_assistant.token[0] != '\0';
}
static void handleSettingsGet()
@@ -345,11 +536,9 @@ static void handleSettingsPost()
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);
copyBool(body["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))
!copyTime(schedule["night_start"], s.schedule.night_start))
{
sendError(400, "Times must look like 08:30 (00:00 - 24:00)");
return;
@@ -362,6 +551,8 @@ static void handleSettingsPost()
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));
copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url));
copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token));
bool restart = false;
const char *error = appSaveSettings(s, restart);
@@ -410,6 +601,14 @@ void webUiBegin()
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/pages", HTTP_GET, handlePages);
server.on("/api/pages/save", HTTP_POST, handlePageSave);
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
server.on("/api/pages/preview", HTTP_POST, handlePagePreview);
server.on("/api/fonts", HTTP_GET, handleFonts);
server.on("/api/fonts/save", HTTP_POST, handleFontSave);
server.on("/api/fonts/delete", HTTP_POST, handleFontDelete);
server.on("/api/ha/value", HTTP_GET, handleHaValue);
server.on("/api/page", HTTP_POST, handlePage);
server.on("/api/live", HTTP_POST, handleLive);
server.on("/api/live/stop", HTTP_POST, handleLiveStop);
+12 -4
View File
@@ -1,8 +1,8 @@
#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.
// choosing the page, dynamic pages and fonts, 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
@@ -11,10 +11,18 @@
// 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}
// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]}
// POST /api/pages/save {name, page} (page format: web/page_json.h)
// POST /api/pages/delete {name}
// POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]}
// GET /api/fonts {fonts:[{name, builtin, height}]}
// POST /api/fonts/save {name, data (hex, format of logic/font.h)}
// POST /api/fonts/delete {name}
// GET /api/ha/value?entity=&attribute= read a Home Assistant value now {value}
// POST /api/page {page: a dynamic page or bitmap name}
// POST /api/live show now {data}
// POST /api/live/stop
// GET /api/settings (the MQTT password is never sent, only password_set)
// GET /api/settings (the MQTT password and HA token are never sent, only *_set)
// POST /api/settings changed fields; {settings, restart}
// POST /api/restart
// POST /api/recovery
+406 -34
View File
@@ -13,8 +13,13 @@
#include "config.h"
#include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
#include "logic/builtin_pages.h"
#include "logic/font.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/page.h"
#include "logic/page_render.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/settings.h"
@@ -49,10 +54,10 @@ class FakeFrames : public Frames
public:
std::vector<std::string> shown;
void clock(int hour, int minute, bool evening) override
void dynamicPage(const LocalTime &time) override
{
char s[24];
snprintf(s, sizeof(s), "clock %02d:%02d%s", hour, minute, evening ? " evening" : "");
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
shown.push_back(s);
}
void page() override { shown.push_back("page"); }
@@ -94,7 +99,10 @@ static void run(int hour, int minute, uint32_t ms = 0)
uint32_t end = now + ms;
do
{
controller->update(now, true, hour, minute, wifi);
LocalTime time;
time.hour = hour;
time.minute = minute;
controller->update(now, true, time, wifi);
now += 100;
} while ((int32_t)(end - now) >= 0);
}
@@ -136,13 +144,6 @@ void test_schedule_night_from_23_31()
TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night);
}
void test_evening_from_17()
{
TEST_ASSERT_FALSE(isEvening(16, 59, defaults.clock));
TEST_ASSERT_TRUE(isEvening(17, 0, defaults.clock));
TEST_ASSERT_TRUE(isEvening(23, 0, defaults.clock));
}
// ---------------------------------------------------------------------------
// Power timer
// ---------------------------------------------------------------------------
@@ -336,7 +337,7 @@ void test_led_blinks_slow_with_wifi_and_fast_without()
void test_nothing_happens_until_time_is_synced()
{
power.is_on = true; // showing "Connecting..."
controller->update(now, false, 0, 0, true);
controller->update(now, false, LocalTime(), true);
TEST_ASSERT_EQUAL(0, power.off_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
@@ -381,7 +382,7 @@ void test_standby_keeps_the_last_time_and_powers_off()
run(22, 0);
run(22, 1, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no off screen
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no off screen
}
void test_quarter_hour_powers_on_again_and_redraws()
@@ -391,7 +392,7 @@ void test_quarter_hour_powers_on_again_and_redraws()
TEST_ASSERT_FALSE(power.is_on);
run(22, 15);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:15 evening", frames.last().c_str());
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
}
void test_clock_cancels_pending_power_off()
@@ -599,7 +600,7 @@ void test_settings_flip_time_and_port_ranges()
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_settings_page_must_be_clock_or_bitmap_name()
void test_settings_page_must_be_a_name()
{
Settings s;
strcpy(s.page, "My bitmap");
@@ -680,7 +681,8 @@ void test_bitmap_names()
TEST_ASSERT_FALSE(validBitmapName("trail "));
TEST_ASSERT_FALSE(validBitmapName("../etc"));
TEST_ASSERT_FALSE(validBitmapName("a/b"));
TEST_ASSERT_FALSE(validBitmapName("Clock")); // the clock page
TEST_ASSERT_TRUE(sameName("Clock", "clock"));
TEST_ASSERT_FALSE(sameName("Clock", "Clocks"));
TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234"));
TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345"));
}
@@ -718,13 +720,6 @@ void test_schedule_without_quarter_hours_stays_on_until_night()
TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night);
}
void test_evening_can_be_disabled()
{
ClockSettings c;
c.evening_enabled = false;
TEST_ASSERT_FALSE(isEvening(20, 0, c));
}
// ---------------------------------------------------------------------------
// Pages and live mode
// ---------------------------------------------------------------------------
@@ -751,7 +746,7 @@ void test_switching_back_to_the_clock_page()
{
controller->setPage(Page::Bitmap);
run(12, 0);
controller->setPage(Page::Clock);
controller->setPage(Page::Dynamic);
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
@@ -765,21 +760,22 @@ void test_bitmap_page_follows_the_schedule()
TEST_ASSERT_FALSE(power.is_on);
}
void test_evening_clock_redrawn_when_the_evening_starts()
void test_dynamic_page_redrawn_when_its_content_changes()
{
run(16, 59);
run(17, 0);
TEST_ASSERT_EQUAL_STRING("clock 17:00 evening", frames.last().c_str());
run(18, 0);
controller->pageChanged(); // e.g. a Home Assistant value changed
run(18, 0);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_settings_change_redraws()
{
run(18, 0);
ClockSettings c;
c.evening_enabled = false;
c.no_wifi_screen = false;
controller->setSettings(defaults.schedule, c);
run(18, 0);
TEST_ASSERT_EQUAL_STRING("clock 18:00", frames.last().c_str());
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_live_is_shown_right_away_even_at_night()
@@ -794,7 +790,7 @@ void test_live_is_shown_right_away_even_at_night()
void test_live_works_before_the_time_is_synced()
{
controller->showLive(now);
controller->update(now, false, 0, 0, true);
controller->update(now, false, LocalTime(), true);
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
}
@@ -834,6 +830,367 @@ void test_live_respects_the_power_switch()
TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live")));
}
// ---------------------------------------------------------------------------
// Fonts
// ---------------------------------------------------------------------------
static Font fontNamed(const char *name)
{
const BuiltinFont *b = findBuiltinFont(name);
TEST_ASSERT_NOT_NULL(b);
return builtinFont(*b);
}
void test_builtin_fonts_are_valid()
{
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
TEST_ASSERT_TRUE_MESSAGE(validFont(BUILTIN_FONTS[i].data, BUILTIN_FONTS[i].size), BUILTIN_FONTS[i].name);
TEST_ASSERT_NOT_NULL(findBuiltinFont("normal"));
TEST_ASSERT_NULL(findBuiltinFont("Comic Sans"));
TEST_ASSERT_EQUAL(2, fontNamed("Large").scale);
}
void test_font_sizes()
{
// Monospaced digits: the clock doesn't move when the time changes
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "12:34"));
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "00:00"));
TEST_ASSERT_EQUAL(58, textWidth(fontNamed("Large"), "12:34"));
TEST_ASSERT_EQUAL(17, textWidth(fontNamed("Small"), "12:34"));
TEST_ASSERT_EQUAL(51, textWidth(fontNamed("Full height"), "12:34"));
TEST_ASSERT_EQUAL(8, fontHeight(fontNamed("Normal")));
TEST_ASSERT_EQUAL(16, fontHeight(fontNamed("Large")));
TEST_ASSERT_EQUAL(6, fontHeight(fontNamed("Small")));
TEST_ASSERT_EQUAL(19, fontHeight(fontNamed("Full height")));
// Characters that are not in the font are skipped
TEST_ASSERT_EQUAL(10, textWidth(fontNamed("Full height"), "a1b"));
TEST_ASSERT_EQUAL(0, textWidth(fontNamed("Normal"), ""));
}
void test_draw_text_uses_the_classic_glyphs()
{
uint8_t b[BITMAP_BYTES] = {};
drawText(b, fontNamed("Normal"), 10, 2, "1", DrawMode::Set);
// glcdfont '1': 0x00, 0x42, 0x7F, 0x40, 0x00 (columns, bit 0 = top)
for (int row = 0; row < 7; row++)
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2 + row));
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 9));
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 3));
TEST_ASSERT_FALSE(bitmapPixel(b, 11, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 13, 8));
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 5));
}
void test_draw_text_scaled_and_inverted()
{
uint8_t b[BITMAP_BYTES];
memset(b, 0xFF, sizeof(b));
drawText(b, fontNamed("Large"), 0, 0, "1", DrawMode::Invert);
TEST_ASSERT_FALSE(bitmapPixel(b, 4, 0)); // column 2 doubled: 4 and 5
TEST_ASSERT_FALSE(bitmapPixel(b, 5, 13));
TEST_ASSERT_TRUE(bitmapPixel(b, 4, 14));
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
}
void test_draw_text_is_clipped()
{
uint8_t b[BITMAP_BYTES] = {};
drawText(b, fontNamed("Full height"), 80, 5, "88", DrawMode::Set); // must not write outside
drawText(b, fontNamed("Full height"), -5, -10, "88", DrawMode::Set);
TEST_ASSERT_TRUE(bitmapPixel(b, 81, 6));
}
void test_invalid_fonts_are_rejected()
{
const BuiltinFont *n = findBuiltinFont("Normal");
std::vector<uint8_t> f(n->data, n->data + n->size);
TEST_ASSERT_TRUE(validFont(f.data(), f.size()));
TEST_ASSERT_FALSE(validFont(f.data(), f.size() - 1));
f.push_back(0);
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
f.pop_back();
f[0] = 'X';
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
f[0] = 'F';
f[4] = 20; // too high
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
TEST_ASSERT_FALSE(validFont(nullptr, 0));
}
// ---------------------------------------------------------------------------
// Dynamic pages
// ---------------------------------------------------------------------------
static DynamicPage twoSlotPage()
{
DynamicPage page;
page.slot_count = 2;
page.slots[0].start = 8 * 60;
strcpy(page.slots[0].bitmap, "Day");
page.slots[1].start = 17 * 60;
strcpy(page.slots[1].bitmap, "Evening");
return page;
}
void test_active_slot_follows_the_time()
{
DynamicPage page = twoSlotPage();
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 7 * 60)->bitmap); // from the day before
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 8 * 60)->bitmap);
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 17 * 60 - 1)->bitmap);
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 17 * 60)->bitmap);
TEST_ASSERT_NULL(activeSlot(DynamicPage(), 600));
}
void test_page_validation()
{
DynamicPage page = twoSlotPage();
TEST_ASSERT_NULL(validatePage(page));
page.slots[1].start = 8 * 60;
TEST_ASSERT_NOT_NULL(validatePage(page)); // not in order
page = twoSlotPage();
page.slots[1].start = 24 * 60;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = twoSlotPage();
strcpy(page.slots[0].bitmap, "../x");
TEST_ASSERT_NOT_NULL(validatePage(page));
page = DynamicPage();
page.element_count = 1;
PageElement &e = page.elements[0];
TEST_ASSERT_NULL(validatePage(page));
e.type = ElementType::HaValue;
TEST_ASSERT_NOT_NULL(validatePage(page)); // needs an entity
strcpy(e.entity, "sensor.outside_temperature");
TEST_ASSERT_NULL(validatePage(page));
strcpy(e.attribute, "bad attr");
TEST_ASSERT_NOT_NULL(validatePage(page));
e.attribute[0] = '\0';
strcpy(e.text, "caf\xc3\xa9");
TEST_ASSERT_NOT_NULL(validatePage(page)); // the fonts have no accents
e.text[0] = '\0';
e.decimals = 5;
TEST_ASSERT_NOT_NULL(validatePage(page));
e.decimals = 1;
e.type = ElementType::Date;
e.format = DATE_FORMAT_COUNT;
TEST_ASSERT_NOT_NULL(validatePage(page));
page.element_count = PAGE_ELEMENTS + 1;
TEST_ASSERT_NOT_NULL(validatePage(page));
TEST_ASSERT_TRUE(validEntityId("weather.home"));
TEST_ASSERT_FALSE(validEntityId("Sensor.x"));
TEST_ASSERT_FALSE(validEntityId("sensor"));
TEST_ASSERT_FALSE(validEntityId("a.b.c"));
TEST_ASSERT_FALSE(validEntityId(".x"));
}
static std::string textOf(const PageElement &e, const char *ha_value = nullptr)
{
LocalTime t; // Thursday 15 October 2026, 08:05
t.year = 2026;
t.month = 10;
t.day = 15;
t.weekday = 4;
t.hour = 8;
t.minute = 5;
char out[64];
elementText(e, t, ha_value, out, sizeof(out));
return out;
}
void test_clock_and_date_texts()
{
PageElement e;
TEST_ASSERT_EQUAL_STRING("08:05", textOf(e).c_str());
e.format = CLOCK_H_MM;
TEST_ASSERT_EQUAL_STRING("8:05", textOf(e).c_str());
e.type = ElementType::Date;
const char *dutch[] = {"do 15 okt", "15 okt", "15 oktober", "15-10", "15-10-2026", "donderdag", "do"};
const char *english[] = {"Thu 15 Oct", "15 Oct", "15 October", "15-10", "15-10-2026", "Thursday", "Thu"};
for (uint8_t f = 0; f < DATE_FORMAT_COUNT; f++)
{
e.format = f;
e.language = Language::Dutch;
TEST_ASSERT_EQUAL_STRING(dutch[f], textOf(e).c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING(english[f], textOf(e).c_str());
}
e = PageElement();
e.type = ElementType::Text;
strcpy(e.text, "Hallo");
TEST_ASSERT_EQUAL_STRING("Hallo", textOf(e).c_str());
}
void test_home_assistant_value_texts()
{
PageElement e;
e.type = ElementType::HaValue;
strcpy(e.text, "T ");
strcpy(e.suffix, "C");
TEST_ASSERT_EQUAL_STRING("T 21.46C", textOf(e, "21.46").c_str());
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, nullptr).c_str());
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, "unavailable").c_str());
e.decimals = 1;
TEST_ASSERT_EQUAL_STRING("T 21.5C", textOf(e, "21.46").c_str());
TEST_ASSERT_EQUAL_STRING("T 0.0C", textOf(e, "-0.04").c_str());
TEST_ASSERT_EQUAL_STRING("T -1.0C", textOf(e, "-1").c_str());
TEST_ASSERT_EQUAL_STRING("T onC", textOf(e, "on").c_str()); // not a number: as is
e.decimals = 0;
TEST_ASSERT_EQUAL_STRING("T 21C", textOf(e, "21.46").c_str());
}
// Backgrounds: "Full" is all dots on, "Day"/"Evening" have one dot at x 0 / x 1
class FakeResources : public PageResources
{
public:
const char *value = "21.5";
bool background(const char *name, uint8_t *bitmap) override
{
memset(bitmap, 0, BITMAP_BYTES);
if (strcmp(name, "Full") == 0)
memset(bitmap, 0xFF, BITMAP_BYTES);
else if (strcmp(name, "Day") == 0)
setBitmapPixel(bitmap, 0, 0, true);
else if (strcmp(name, "Evening") == 0)
setBitmapPixel(bitmap, 1, 0, true);
else
{
memset(bitmap, 0xAA, BITMAP_BYTES); // garbage: must be cleared
return false;
}
return true;
}
Font font(const char *name) override
{
const BuiltinFont *b = findBuiltinFont(name);
return b ? builtinFont(*b) : Font();
}
const char *haValue(const PageElement &) override { return value; }
};
static LocalTime at(int hour, int minute)
{
LocalTime t;
t.hour = hour;
t.minute = minute;
return t;
}
void test_render_background_follows_the_slots()
{
FakeResources res;
DynamicPage page = twoSlotPage();
uint8_t b[BITMAP_BYTES];
renderPage(page, at(9, 0), res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0));
renderPage(page, at(18, 0), res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0));
strcpy(page.slots[1].bitmap, "Gone");
renderPage(page, at(18, 0), res, b);
for (int i = 0; i < BITMAP_BYTES; i++)
TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank
}
void test_render_overlays()
{
FakeResources res;
DynamicPage page;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "Full");
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Text;
strcpy(e.text, "1");
e.x = 10;
e.y = 2;
uint8_t b[BITMAP_BYTES];
e.overlay = Overlay::OnTop; // on a full background nothing changes
renderPage(page, at(12, 0), res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
e.overlay = Overlay::Box; // cleared area with one dot around it, then the text
renderPage(page, at(12, 0), res, b);
TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1));
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
TEST_ASSERT_FALSE(bitmapPixel(b, 15, 10));
TEST_ASSERT_TRUE(bitmapPixel(b, 16, 10));
TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2));
e.overlay = Overlay::Invert;
renderPage(page, at(12, 0), res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2));
}
void test_render_alignment_and_boxes()
{
FakeResources res;
DynamicPage page;
page.element_count = 2;
page.elements[0].x = 84;
page.elements[0].align = Align::Right;
page.elements[1].type = ElementType::HaValue;
strcpy(page.elements[1].entity, "sensor.t");
page.elements[1].x = 42;
page.elements[1].y = 11;
page.elements[1].align = Align::Center;
ElementBox boxes[2];
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b, boxes);
TEST_ASSERT_EQUAL(84 - 29, boxes[0].x);
TEST_ASSERT_EQUAL(29, boxes[0].width);
TEST_ASSERT_EQUAL(8, boxes[0].height);
TEST_ASSERT_EQUAL(23, boxes[1].width); // "21.5"
TEST_ASSERT_EQUAL(42 - 11, boxes[1].x);
TEST_ASSERT_EQUAL(11, boxes[1].y);
}
void test_render_unknown_font_uses_normal()
{
FakeResources res;
DynamicPage page;
page.element_count = 1;
strcpy(page.elements[0].font, "Gone");
ElementBox box;
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b, &box);
TEST_ASSERT_EQUAL(29, box.width);
}
void test_builtin_clock_page_is_the_old_clock()
{
DynamicPage page;
TEST_ASSERT_TRUE(builtinPage("clock", page)); // old settings stored "clock"
TEST_ASSERT_NULL(validatePage(page));
TEST_ASSERT_EQUAL_STRING("Pikachu", activeSlot(page, 16 * 60 + 59)->bitmap);
TEST_ASSERT_EQUAL_STRING("Pikachu yawning", activeSlot(page, 17 * 60)->bitmap);
TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_CLOCK, builtinPageName(0));
TEST_ASSERT_FALSE(builtinPage("Pikachu", page));
// The time where the old firmware printed it: cursor 46, 4 with the classic font
FakeResources res;
TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page));
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1'
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10));
TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11));
}
void test_default_page_is_the_builtin_clock()
{
DynamicPage page;
TEST_ASSERT_TRUE(builtinPage(defaults.page, page));
}
int main()
{
UNITY_BEGIN();
@@ -842,7 +1199,6 @@ int main()
RUN_TEST(test_schedule_clock_from_8_to_22);
RUN_TEST(test_schedule_quarter_hours_until_23_30);
RUN_TEST(test_schedule_night_from_23_31);
RUN_TEST(test_evening_from_17);
RUN_TEST(test_timer_expires_once_after_delay);
RUN_TEST(test_timer_keeps_earlier_deadline);
@@ -894,7 +1250,7 @@ int main()
RUN_TEST(test_settings_off_page_blank_or_bitmap_name);
RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on);
RUN_TEST(test_settings_flip_time_and_port_ranges);
RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name);
RUN_TEST(test_settings_page_must_be_a_name);
RUN_TEST(test_time_parse_and_format);
RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format);
RUN_TEST(test_bitmap_hex_round_trip);
@@ -903,12 +1259,11 @@ int main()
RUN_TEST(test_schedule_disabled_is_always_on);
RUN_TEST(test_schedule_custom_times);
RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night);
RUN_TEST(test_evening_can_be_disabled);
RUN_TEST(test_bitmap_page_is_shown_once);
RUN_TEST(test_bitmap_page_redrawn_when_changed);
RUN_TEST(test_switching_back_to_the_clock_page);
RUN_TEST(test_bitmap_page_follows_the_schedule);
RUN_TEST(test_evening_clock_redrawn_when_the_evening_starts);
RUN_TEST(test_dynamic_page_redrawn_when_its_content_changes);
RUN_TEST(test_settings_change_redraws);
RUN_TEST(test_live_is_shown_right_away_even_at_night);
RUN_TEST(test_live_works_before_the_time_is_synced);
@@ -917,5 +1272,22 @@ int main()
RUN_TEST(test_live_ends_after_timeout);
RUN_TEST(test_live_respects_the_power_switch);
RUN_TEST(test_builtin_fonts_are_valid);
RUN_TEST(test_font_sizes);
RUN_TEST(test_draw_text_uses_the_classic_glyphs);
RUN_TEST(test_draw_text_scaled_and_inverted);
RUN_TEST(test_draw_text_is_clipped);
RUN_TEST(test_invalid_fonts_are_rejected);
RUN_TEST(test_active_slot_follows_the_time);
RUN_TEST(test_page_validation);
RUN_TEST(test_clock_and_date_texts);
RUN_TEST(test_home_assistant_value_texts);
RUN_TEST(test_render_background_follows_the_slots);
RUN_TEST(test_render_overlays);
RUN_TEST(test_render_alignment_and_boxes);
RUN_TEST(test_render_unknown_font_uses_normal);
RUN_TEST(test_builtin_clock_page_is_the_old_clock);
RUN_TEST(test_default_page_is_the_builtin_clock);
return UNITY_END();
}
+204
View File
@@ -0,0 +1,204 @@
"""Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages.
python tools/make_builtin_fonts.py
Needs the Adafruit GFX library in .pio/libdeps/esp32 (run `pio run -e esp32` once):
Small TomThumb 3x5 (Fonts/TomThumb.h, BSD license, see that file)
Normal the classic 5x7 font (glcdfont.c), the font the clock always used
Full height digits drawn below (seven segments with rounded corners, 19 dots high)
"Large" is Normal drawn twice as big, so it has no data of its own.
The font format is described in src/esp32/logic/font.h. Only the Python standard
library is used.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GFX = os.path.join(ROOT, ".pio", "libdeps", "esp32", "Adafruit GFX Library")
OUT = os.path.join(ROOT, "src", "esp32", "logic", "builtin_fonts.cpp")
FIRST = 32
LAST = 126
def numbers(text):
return [int(v, 0) for v in re.findall(r"0x[0-9A-Fa-f]+|-?\d+", text)]
def array_body(source, name):
match = re.search(name + r"\[\]\s*PROGMEM\s*=\s*\{(.*?)\};", source, re.S)
if not match:
sys.exit("Not found: " + name)
# drop comments, they contain numbers too
return re.sub(r"/\*.*?\*/|//[^\n]*", "", match.group(1), flags=re.S)
def encode(name, height, spacing, glyphs):
"""glyphs: list of (width, rows) per character FIRST..LAST, rows = list of strings"""
data = bytearray(b"FDF1")
data += bytes([height, spacing, FIRST, len(glyphs)])
data += bytes(width for width, _ in glyphs)
for width, rows in glyphs:
assert len(rows) == height, name
for row in rows:
for start in range(0, width, 8):
byte = 0
for bit in range(8):
x = start + bit
if x < width and row[x] == "#":
byte |= 0x80 >> bit
data.append(byte)
return bytes(data)
def classic():
source = open(os.path.join(GFX, "glcdfont.c")).read()
font = numbers(array_body(source, "font"))
glyphs = []
for c in range(FIRST, LAST + 1):
columns = font[c * 5 : c * 5 + 5]
rows = ["".join("#" if columns[x] >> y & 1 else "." for x in range(5)) for y in range(8)]
glyphs.append((5, rows))
return encode("Normal", 8, 1, glyphs)
def tom_thumb():
source = open(os.path.join(GFX, "Fonts", "TomThumb.h")).read()
bitmaps = numbers(array_body(source, "TomThumbBitmaps"))
table = numbers(array_body(source, "TomThumbGlyphs"))
glyphs = []
for i, c in enumerate(range(FIRST, LAST + 1)):
offset, w, h, advance, x_offset, y_offset = table[i * 6 : i * 6 + 6]
width = max(advance - 1, 1)
rows = [["."] * width for _ in range(6)]
bit = 0
for y in range(h):
for x in range(w):
byte = bitmaps[offset + bit // 8]
if byte & (0x80 >> (bit % 8)):
rows[5 + y_offset + y][x_offset + x] = "#"
bit += 1
if c == ord(" "):
rows = [["."] * 2 for _ in range(6)]
width = 2
glyphs.append((width, ["".join(r) for r in rows]))
# Digits all as wide as the widest one (right aligned), so a clock doesn't move
digits = range(ord("0") - FIRST, ord("9") - FIRST + 1)
widest = max(glyphs[i][0] for i in digits)
for i in digits:
width, rows = glyphs[i]
glyphs[i] = (widest, ["." * (widest - width) + r for r in rows])
return encode("Small", 6, 1, glyphs)
# Full height digits: seven segments, 10 x 19 dots
SEGMENTS = {
"a": (0, 0, 10, 2), # x, y, width, height
"b": (7, 0, 3, 11),
"c": (7, 8, 3, 11),
"d": (0, 17, 10, 2),
"e": (0, 8, 3, 11),
"f": (0, 0, 3, 11),
"g": (0, 8, 10, 3),
}
DIGITS = ["abcdef", "bc", "abdeg", "abcdg", "bcfg", "acdfg", "acdefg", "abc", "abcdefg", "abcdfg"]
def rounded(rows):
"""Rounds the outer corners: clears a corner dot of the glyph box or of the middle bar
when it has no neighbour above or below and none left or right"""
height, width = len(rows), len(rows[0])
on = lambda x, y: 0 <= x < width and 0 <= y < height and rows[y][x] == "#"
out = [list(r) for r in rows]
for y in (0, 8, 10, height - 1):
for x in (0, width - 1):
if on(x, y) and not (on(x - 1, y) and on(x + 1, y)) and not (on(x, y - 1) and on(x, y + 1)) and (not on(x - 1, y) or not on(x + 1, y)) and (not on(x, y - 1) or not on(x, y + 1)):
out[y][x] = "."
return ["".join(r) for r in out]
def box(width, height, dots):
rows = [["."] * width for _ in range(height)]
for x, y, w, h in dots:
for yy in range(y, y + h):
for xx in range(x, x + w):
rows[yy][xx] = "#"
return ["".join(r) for r in rows]
def full_height():
special = {
" ": box(4, 19, []),
":": box(3, 19, [(0, 4, 3, 3), (0, 12, 3, 3)]),
".": box(3, 19, [(0, 16, 3, 3)]),
"-": box(6, 19, [(0, 8, 6, 3)]),
}
glyphs = []
for c in range(FIRST, LAST + 1):
ch = chr(c)
if ch.isdigit():
rows = rounded(box(10, 19, [SEGMENTS[s] for s in DIGITS[int(ch)]]))
glyphs.append((10, rows))
elif ch in special:
glyphs.append((len(special[ch][0]), special[ch]))
else:
glyphs.append((0, [""] * 19)) # not in this font: skipped
return encode("Full height", 19, 2, glyphs)
def c_array(name, data):
lines = []
for i in range(0, len(data), 16):
lines.append("\t" + ", ".join("0x%02x" % b for b in data[i : i + 16]) + ",")
return "static const uint8_t %s[] = {\n%s\n};\n" % (name, "\n".join(lines))
LOOKUP = """
const BuiltinFont *findBuiltinFont(const char *name)
{
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
if (sameName(BUILTIN_FONTS[i].name, name))
return &BUILTIN_FONTS[i];
return nullptr;
}
Font builtinFont(const BuiltinFont &font)
{
Font f;
f.data = font.data;
f.size = font.size;
f.scale = font.scale;
return f;
}
"""
def main():
fonts = [("FONT_SMALL", tom_thumb()), ("FONT_NORMAL", classic()), ("FONT_FULL", full_height())]
out = [
"// Generated by tools/make_builtin_fonts.py, do not edit.\n",
"// Small: TomThumb (BSD license, Brian J. Swetland, Vassilii Khachaturov, Dan Marks,\n",
"// Robey Pointer); Normal: Adafruit GFX glcdfont (BSD license, Adafruit Industries).\n\n",
'#include "logic/builtin_fonts.h"\n\n',
'#include "logic/bitmap_format.h"\n\n',
]
for name, data in fonts:
out.append(c_array(name, data) + "\n")
out.append("const BuiltinFont BUILTIN_FONTS[] = {\n")
out.append('\t{"Small", FONT_SMALL, sizeof(FONT_SMALL), 1},\n')
out.append('\t{"Normal", FONT_NORMAL, sizeof(FONT_NORMAL), 1},\n')
out.append('\t{"Large", FONT_NORMAL, sizeof(FONT_NORMAL), 2},\n')
out.append('\t{"Full height", FONT_FULL, sizeof(FONT_FULL), 1},\n')
out.append("};\n")
out.append("const int BUILTIN_FONT_COUNT = sizeof(BUILTIN_FONTS) / sizeof(BUILTIN_FONTS[0]);\n")
out.append(LOOKUP)
with open(OUT, "w", newline="\n") as f:
f.write("".join(out))
print("Wrote", OUT)
if __name__ == "__main__":
main()