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

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