Public Access
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:
1 parent
1d3b084de1
commit
0411804a2f
49 files changed
+3058
-331
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user