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

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