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

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