Public Access
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>
204 lines
6.6 KiB
C++
204 lines
6.6 KiB
C++
#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;
|
|
}
|
|
}
|