2026-10-15 19:43:59 +02:00
|
|
|
#include "logic/page.h"
|
|
|
|
|
|
|
|
|
|
#include <ctype.h>
|
2026-10-15 21:01:14 +02:00
|
|
|
#include <math.h>
|
2026-10-15 19:43:59 +02:00
|
|
|
#include <stdio.h>
|
|
|
|
|
#include <stdlib.h>
|
|
|
|
|
#include <string.h>
|
|
|
|
|
|
|
|
|
|
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
2026-10-15 21:01:14 +02:00
|
|
|
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
|
2026-10-15 19:43:59 +02:00
|
|
|
|
|
|
|
|
static bool terminated(const char *text, size_t size)
|
|
|
|
|
{
|
|
|
|
|
return memchr(text, '\0', size) != nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
// Plain ASCII and the degree sign only: the fonts have no other characters
|
2026-10-15 19:43:59 +02:00
|
|
|
static bool printable(const char *text, size_t size)
|
|
|
|
|
{
|
|
|
|
|
if (!terminated(text, size))
|
|
|
|
|
return false;
|
|
|
|
|
for (const char *c = text; *c; c++)
|
2026-10-15 21:01:14 +02:00
|
|
|
if (*c < 32 || *c > DEGREE_SIGN)
|
2026-10-15 19:43:59 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:50:19 +02:00
|
|
|
static bool strncasecmpAscii(const char *a, const char *b, size_t n)
|
|
|
|
|
{
|
|
|
|
|
for (size_t i = 0; i < n; i++)
|
|
|
|
|
if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i]))
|
|
|
|
|
return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
static bool validAttribute(const char *attribute)
|
|
|
|
|
{
|
|
|
|
|
for (const char *c = attribute; *c; c++)
|
|
|
|
|
if (!isalnum((unsigned char)*c) && *c != '_')
|
|
|
|
|
return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:50:19 +02:00
|
|
|
static uint8_t formatCount(const PageElement &e)
|
2026-10-15 21:01:14 +02:00
|
|
|
{
|
2026-10-15 21:50:19 +02:00
|
|
|
switch (e.type)
|
2026-10-15 21:01:14 +02:00
|
|
|
{
|
2026-10-15 21:50:19 +02:00
|
|
|
case ElementType::HaValue:
|
|
|
|
|
if (e.show == HaShow::Duration)
|
|
|
|
|
return DURATION_FORMAT_COUNT;
|
|
|
|
|
if (e.show == HaShow::Time)
|
|
|
|
|
return TIMESTAMP_FORMAT_COUNT;
|
|
|
|
|
return 255; // not used
|
2026-10-15 21:01:14 +02:00
|
|
|
case ElementType::Clock:
|
|
|
|
|
return CLOCK_FORMAT_COUNT;
|
|
|
|
|
case ElementType::Date:
|
|
|
|
|
return DATE_FORMAT_COUNT;
|
|
|
|
|
case ElementType::Countdown:
|
|
|
|
|
return COUNTDOWN_FORMAT_COUNT;
|
|
|
|
|
case ElementType::Stopwatch:
|
|
|
|
|
return STOPWATCH_FORMAT_COUNT;
|
|
|
|
|
default:
|
|
|
|
|
return 255; // not used
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
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)))
|
2026-10-15 21:01:14 +02:00
|
|
|
return "Texts can only contain plain characters (no accents) and the degree sign, up to 32";
|
2026-10-15 19:43:59 +02:00
|
|
|
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";
|
2026-10-15 21:50:19 +02:00
|
|
|
if (e.show >= HaShow::Count || e.input_unit >= UNIT_COUNT)
|
|
|
|
|
return "Unknown Home Assistant value setting";
|
|
|
|
|
if (!printable(e.map, sizeof(e.map)))
|
|
|
|
|
return "The list of texts can only contain plain characters, up to 63";
|
|
|
|
|
if (e.format >= formatCount(e))
|
2026-10-15 21:01:14 +02:00
|
|
|
return "Unknown format";
|
|
|
|
|
if (!isfinite(e.factor) || e.factor == 0)
|
|
|
|
|
return "The multiplier must be a number other than 0";
|
|
|
|
|
if (e.scroll_width > 2 * BITMAP_WIDTH ||
|
|
|
|
|
(e.scroll_width && (e.scroll_speed < SCROLL_SPEED_MIN || e.scroll_speed > SCROLL_SPEED_MAX)))
|
|
|
|
|
return "Scrolling: window up to 168 dots, speed 1-30 dots per second";
|
2026-10-15 19:43:59 +02:00
|
|
|
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
|
|
|
|
|
return "Home Assistant values need an entity id like sensor.outside_temperature";
|
2026-10-15 21:01:14 +02:00
|
|
|
if (e.type == ElementType::Countdown && e.target >= MAX_TARGET)
|
|
|
|
|
return "The countdown date must be before 2100";
|
|
|
|
|
if (e.type == ElementType::Image)
|
|
|
|
|
{
|
|
|
|
|
if (e.entity[0] && !validEntityId(e.entity))
|
|
|
|
|
return "Invalid entity id";
|
|
|
|
|
char name[ELEMENT_TEXT_SIZE + 1];
|
|
|
|
|
snprintf(name, sizeof(name), "%sx", e.text); // a prefix must be the start of a valid name
|
|
|
|
|
if (e.entity[0] ? (e.text[0] == ' ' || !validBitmapName(name)) : !validBitmapName(e.text))
|
|
|
|
|
return "Images need a bitmap name (or the start of one, with an entity)";
|
|
|
|
|
}
|
2026-10-15 19:43:59 +02:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Dates
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Days since 1970-01-01 (proleptic Gregorian calendar)
|
|
|
|
|
static int32_t daysFromCivil(int y, int m, int d)
|
|
|
|
|
{
|
|
|
|
|
y -= m <= 2;
|
|
|
|
|
int32_t era = (y >= 0 ? y : y - 399) / 400;
|
|
|
|
|
int32_t yoe = y - era * 400;
|
|
|
|
|
int32_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
|
|
|
|
int32_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
|
|
|
return era * 146097 + doe - 719468;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void civilFromDays(int32_t z, int &y, int &m, int &d)
|
|
|
|
|
{
|
|
|
|
|
z += 719468;
|
|
|
|
|
int32_t era = (z >= 0 ? z : z - 146096) / 146097;
|
|
|
|
|
int32_t doe = z - era * 146097;
|
|
|
|
|
int32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
|
|
|
int32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
|
|
|
int32_t mp = (5 * doy + 2) / 153;
|
|
|
|
|
d = doy - (153 * mp + 2) / 5 + 1;
|
|
|
|
|
m = mp < 10 ? mp + 3 : mp - 9;
|
|
|
|
|
y = yoe + era * 400 + (m <= 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const int32_t DAYS_1970_TO_2000 = 10957;
|
|
|
|
|
|
|
|
|
|
uint32_t minutesSince2000(const LocalTime &t)
|
|
|
|
|
{
|
|
|
|
|
int32_t days = daysFromCivil(t.year, t.month, t.day) - DAYS_1970_TO_2000;
|
|
|
|
|
if (days < 0)
|
|
|
|
|
return 0;
|
|
|
|
|
return (uint32_t)days * MINUTES_PER_DAY + t.hour * 60 + t.minute;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:50:19 +02:00
|
|
|
bool parseTimestamp(const char *text, int64_t &seconds)
|
|
|
|
|
{
|
|
|
|
|
if (text == nullptr)
|
|
|
|
|
return false;
|
|
|
|
|
int y, mo, d, h, mi, s;
|
|
|
|
|
char sep;
|
|
|
|
|
int used = 0;
|
|
|
|
|
if (sscanf(text, "%4d-%2d-%2d%c%2d:%2d:%2d%n", &y, &mo, &d, &sep, &h, &mi, &s, &used) != 7 ||
|
|
|
|
|
(sep != 'T' && sep != ' '))
|
|
|
|
|
return false;
|
|
|
|
|
if (y < 2000 || y > 2099 || mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60)
|
|
|
|
|
return false;
|
|
|
|
|
const char *rest = text + used;
|
|
|
|
|
if (*rest == '.') // fractions of a second
|
|
|
|
|
for (rest++; isdigit((unsigned char)*rest); rest++)
|
|
|
|
|
;
|
|
|
|
|
int offset = 0; // minutes east of UTC
|
|
|
|
|
if (*rest == '+' || *rest == '-')
|
|
|
|
|
{
|
|
|
|
|
int oh, om;
|
|
|
|
|
if (sscanf(rest + 1, "%2d:%2d", &oh, &om) != 2)
|
|
|
|
|
return false;
|
|
|
|
|
offset = (oh * 60 + om) * (*rest == '-' ? -1 : 1);
|
|
|
|
|
rest += 6;
|
|
|
|
|
}
|
|
|
|
|
else if (*rest == 'Z')
|
|
|
|
|
rest++;
|
|
|
|
|
if (*rest != '\0')
|
|
|
|
|
return false;
|
|
|
|
|
int64_t days = daysFromCivil(y, mo, d) - DAYS_1970_TO_2000;
|
|
|
|
|
seconds = days * 86400 + h * 3600 + mi * 60 + s - offset * 60;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
bool parseTarget(const char *text, uint32_t &minutes)
|
|
|
|
|
{
|
|
|
|
|
if (text == nullptr || strlen(text) != 16)
|
|
|
|
|
return false;
|
|
|
|
|
int y, mo, d, h, mi;
|
|
|
|
|
char extra;
|
|
|
|
|
if (sscanf(text, "%4d-%2d-%2dT%2d:%2d%c", &y, &mo, &d, &h, &mi, &extra) != 5)
|
|
|
|
|
return false;
|
|
|
|
|
if (y < 2000 || y > 2099 || mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || h < 0 || mi < 0)
|
|
|
|
|
return false;
|
|
|
|
|
int cy, cm, cd; // rejects e.g. 31 February
|
|
|
|
|
int32_t days = daysFromCivil(y, mo, d);
|
|
|
|
|
civilFromDays(days, cy, cm, cd);
|
|
|
|
|
if (cd != d)
|
|
|
|
|
return false;
|
|
|
|
|
minutes = (uint32_t)(days - DAYS_1970_TO_2000) * MINUTES_PER_DAY + h * 60 + mi;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void formatTarget(uint32_t minutes, char *out)
|
|
|
|
|
{
|
|
|
|
|
int y, m, d;
|
|
|
|
|
civilFromDays(minutes / MINUTES_PER_DAY + DAYS_1970_TO_2000, y, m, d);
|
|
|
|
|
unsigned minute_of_day = minutes % MINUTES_PER_DAY;
|
|
|
|
|
snprintf(out, 17, "%04d-%02d-%02dT%02u:%02u", y % 10000, m, d, minute_of_day / 60, minute_of_day % 60);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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"},
|
|
|
|
|
};
|
2026-10-15 21:01:14 +02:00
|
|
|
// Countdown units: singular, plural
|
|
|
|
|
static const char *const DAYS_WORD[][2] = {{"dag", "dagen"}, {"day", "days"}};
|
|
|
|
|
static const char *const HOURS_WORD[][2] = {{"uur", "uur"}, {"hour", "hours"}};
|
|
|
|
|
|
|
|
|
|
static int language(const PageElement &e)
|
|
|
|
|
{
|
|
|
|
|
return e.language < Language::Count ? (int)e.language : 0;
|
|
|
|
|
}
|
2026-10-15 19:43:59 +02:00
|
|
|
|
|
|
|
|
static void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size)
|
|
|
|
|
{
|
2026-10-15 21:01:14 +02:00
|
|
|
int lang = language(e);
|
2026-10-15 19:43:59 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:50:19 +02:00
|
|
|
static bool parseNumber(const char *text, double &v)
|
2026-10-15 19:43:59 +02:00
|
|
|
{
|
2026-10-15 21:01:14 +02:00
|
|
|
char *end;
|
2026-10-15 21:50:19 +02:00
|
|
|
v = strtod(text, &end);
|
|
|
|
|
return end != text && *end == '\0';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void formatNumber(double v, int decimals, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
if (decimals >= 0)
|
|
|
|
|
snprintf(out, size, "%.*f", decimals, v);
|
|
|
|
|
else
|
|
|
|
|
snprintf(out, size, "%.6g", v);
|
|
|
|
|
// -0.0 after rounding: no minus sign
|
|
|
|
|
if (out[0] == '-' && strspn(out + 1, "0.") == strlen(out + 1))
|
|
|
|
|
memmove(out, out + 1, strlen(out));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Units that get k (1000x) and M (1000000x) in front with auto unit
|
|
|
|
|
static const char *const SCALED_UNITS[] = {"W", "Wh", "VA", "g", "m", "B", "Hz"};
|
|
|
|
|
|
|
|
|
|
static bool scaledUnit(const char *unit)
|
|
|
|
|
{
|
|
|
|
|
for (const char *u : SCALED_UNITS)
|
|
|
|
|
if (strcmp(unit, u) == 0)
|
|
|
|
|
return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Number: multiplier, auto unit, no minus sign, decimals. unit: in/out, the text after.
|
|
|
|
|
static void showNumber(const PageElement &e, double v, char *out, size_t size, char *unit, size_t unit_size)
|
|
|
|
|
{
|
|
|
|
|
v *= e.factor;
|
|
|
|
|
if (e.absolute && v < 0)
|
|
|
|
|
v = -v;
|
|
|
|
|
int decimals = e.decimals;
|
|
|
|
|
if (e.auto_unit)
|
2026-10-15 19:43:59 +02:00
|
|
|
{
|
2026-10-15 21:50:19 +02:00
|
|
|
// The unit is the text after, without the spaces before it
|
|
|
|
|
size_t spaces = strspn(unit, " ");
|
|
|
|
|
char base[ELEMENT_SUFFIX_SIZE];
|
|
|
|
|
snprintf(base, sizeof(base), "%s", unit + spaces);
|
|
|
|
|
double a = v < 0 ? -v : v;
|
|
|
|
|
char prefix = 0;
|
|
|
|
|
const char *rest = base;
|
|
|
|
|
if ((base[0] == 'k' || base[0] == 'M') && scaledUnit(base + 1))
|
|
|
|
|
{
|
|
|
|
|
prefix = base[0];
|
|
|
|
|
rest = base + 1;
|
|
|
|
|
}
|
|
|
|
|
else if (!scaledUnit(base))
|
|
|
|
|
rest = nullptr;
|
|
|
|
|
if (rest)
|
|
|
|
|
{
|
|
|
|
|
char next = prefix;
|
|
|
|
|
if (a >= 1000 && prefix != 'M')
|
|
|
|
|
{
|
|
|
|
|
v /= 1000;
|
|
|
|
|
next = prefix ? 'M' : 'k';
|
|
|
|
|
}
|
|
|
|
|
else if (a < 1 && a > 0 && prefix)
|
|
|
|
|
{
|
|
|
|
|
v *= 1000;
|
|
|
|
|
next = prefix == 'M' ? 'k' : 0;
|
|
|
|
|
}
|
|
|
|
|
if (next != prefix)
|
|
|
|
|
{
|
|
|
|
|
char scaled[ELEMENT_SUFFIX_SIZE + 1];
|
|
|
|
|
const char new_prefix[2] = {next, '\0'};
|
|
|
|
|
snprintf(scaled, sizeof(scaled), "%.*s%s%s", (int)spaces, unit, new_prefix, rest);
|
|
|
|
|
snprintf(unit, unit_size, "%s", scaled);
|
|
|
|
|
if (decimals < 0)
|
|
|
|
|
decimals = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-15 19:43:59 +02:00
|
|
|
}
|
2026-10-15 21:50:19 +02:00
|
|
|
formatNumber(v, decimals, out, size);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const char *const DAYS_SHORT_WORD[] = {"d", "d"};
|
|
|
|
|
|
|
|
|
|
// Duration: a number of seconds, minutes or hours
|
|
|
|
|
static void showDuration(const PageElement &e, double v, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
static const double UNITS[] = {1, 60, 3600};
|
|
|
|
|
v *= e.factor * UNITS[e.input_unit < UNIT_COUNT ? e.input_unit : 0];
|
|
|
|
|
const char *sign = v < 0 ? "-" : "";
|
|
|
|
|
long long total = (long long)(v < 0 ? -v : v);
|
|
|
|
|
long long days = total / 86400, hours = total % 86400 / 3600, minutes = total % 3600 / 60, seconds = total % 60;
|
|
|
|
|
int lang = e.language < Language::Count ? (int)e.language : 0;
|
|
|
|
|
switch (e.format)
|
|
|
|
|
{
|
|
|
|
|
case DURATION_H_MM_SS:
|
|
|
|
|
snprintf(out, size, "%s%lld:%02lld:%02lld", sign, total / 3600, minutes, seconds);
|
|
|
|
|
break;
|
|
|
|
|
case DURATION_H_MM:
|
|
|
|
|
snprintf(out, size, "%s%lld:%02lld", sign, total / 3600, minutes);
|
|
|
|
|
break;
|
|
|
|
|
case DURATION_SMART:
|
|
|
|
|
if (days)
|
|
|
|
|
snprintf(out, size, "%s%lld %s", sign, days, DAYS_WORD[lang][days != 1]);
|
|
|
|
|
else if (hours)
|
|
|
|
|
snprintf(out, size, "%s%lld %s", sign, hours, HOURS_WORD[lang][hours != 1]);
|
|
|
|
|
else if (minutes)
|
|
|
|
|
snprintf(out, size, "%s%lld min", sign, minutes);
|
|
|
|
|
else
|
|
|
|
|
snprintf(out, size, "%s%lld s", sign, seconds);
|
|
|
|
|
break;
|
|
|
|
|
default: // the parts that are not 0: 2d 3h 15m; below a minute 45s
|
|
|
|
|
{
|
|
|
|
|
if (total < 60)
|
|
|
|
|
{
|
|
|
|
|
snprintf(out, size, "%s%llds", sign, seconds);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
size_t n = snprintf(out, size, "%s", sign);
|
|
|
|
|
if (days && n < size)
|
|
|
|
|
n += snprintf(out + n, size - n, "%lld%s", days, DAYS_SHORT_WORD[lang]);
|
|
|
|
|
if (hours && n < size)
|
|
|
|
|
n += snprintf(out + n, size - n, "%s%lldh", days ? " " : "", hours);
|
|
|
|
|
if (minutes && n < size)
|
|
|
|
|
snprintf(out + n, size - n, "%s%lldm", days || hours ? " " : "", minutes);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Time: a timestamp in local time, or how long until / ago
|
|
|
|
|
static bool showTimestamp(const PageElement &e, const char *value, const LocalTime &t, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
int64_t utc;
|
|
|
|
|
if (!parseTimestamp(value, utc))
|
|
|
|
|
return false;
|
|
|
|
|
int lang = e.language < Language::Count ? (int)e.language : 0;
|
|
|
|
|
if (e.format == TIMESTAMP_RELATIVE)
|
|
|
|
|
{
|
|
|
|
|
int64_t now = (int64_t)minutesSince2000(t) * 60 + t.second - t.utc_offset * 60;
|
|
|
|
|
int64_t diff = utc - now;
|
|
|
|
|
int64_t a = diff < 0 ? -diff : diff;
|
|
|
|
|
if (a < 60)
|
|
|
|
|
{
|
|
|
|
|
snprintf(out, size, "%s", lang ? "now" : "nu");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
char amount[24];
|
|
|
|
|
if (a < 3600)
|
|
|
|
|
snprintf(amount, sizeof(amount), "%lld min", (long long)(a / 60));
|
|
|
|
|
else if (a < 86400)
|
|
|
|
|
snprintf(amount, sizeof(amount), "%lld %s", (long long)(a / 3600), HOURS_WORD[lang][a / 3600 != 1]);
|
|
|
|
|
else
|
|
|
|
|
snprintf(amount, sizeof(amount), "%lld %s", (long long)(a / 86400), DAYS_WORD[lang][a / 86400 != 1]);
|
|
|
|
|
if (diff > 0)
|
|
|
|
|
snprintf(out, size, "%s %s", lang ? "in" : "over", amount);
|
|
|
|
|
else
|
|
|
|
|
snprintf(out, size, "%s %s", amount, lang ? "ago" : "geleden");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
int64_t local = utc + t.utc_offset * 60;
|
|
|
|
|
int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400;
|
|
|
|
|
int minute_of_day = (int)((local - days * 86400) / 60);
|
|
|
|
|
int y, m, d;
|
|
|
|
|
civilFromDays((int32_t)(days + DAYS_1970_TO_2000), y, m, d);
|
|
|
|
|
int weekday = (int)((days + DAYS_1970_TO_2000 + 4) % 7); // 1970-01-01 was a Thursday
|
|
|
|
|
switch (e.format)
|
|
|
|
|
{
|
|
|
|
|
case TIMESTAMP_WEEKDAY_H_MM:
|
|
|
|
|
snprintf(out, size, "%s %02d:%02d", WEEKDAYS_SHORT[lang][weekday], minute_of_day / 60, minute_of_day % 60);
|
|
|
|
|
break;
|
|
|
|
|
case TIMESTAMP_DATE_H_MM:
|
|
|
|
|
snprintf(out, size, "%02d-%02d %02d:%02d", d, m, minute_of_day / 60, minute_of_day % 60);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
snprintf(out, size, "%02d:%02d", minute_of_day / 60, minute_of_day % 60);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map: "state=text, state=text"; false when the state is not in it
|
|
|
|
|
static bool showMapped(const PageElement &e, const char *value, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
const char *p = e.map;
|
|
|
|
|
while (*p)
|
|
|
|
|
{
|
|
|
|
|
const char *end = strchr(p, ',');
|
|
|
|
|
if (!end)
|
|
|
|
|
end = p + strlen(p);
|
|
|
|
|
const char *eq = (const char *)memchr(p, '=', end - p);
|
|
|
|
|
if (eq)
|
|
|
|
|
{
|
|
|
|
|
const char *k = p;
|
|
|
|
|
while (k < eq && *k == ' ')
|
|
|
|
|
k++;
|
|
|
|
|
const char *k_end = eq;
|
|
|
|
|
while (k_end > k && k_end[-1] == ' ')
|
|
|
|
|
k_end--;
|
|
|
|
|
size_t len = k_end - k;
|
|
|
|
|
if (len == strlen(value) && strncasecmpAscii(k, value, len))
|
|
|
|
|
{
|
|
|
|
|
const char *v = eq + 1;
|
|
|
|
|
while (v < end && *v == ' ')
|
|
|
|
|
v++;
|
|
|
|
|
const char *v_end = end;
|
|
|
|
|
while (v_end > v && v_end[-1] == ' ')
|
|
|
|
|
v_end--;
|
|
|
|
|
snprintf(out, size, "%.*s", (int)(v_end - v), v);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
p = *end ? end + 1 : end;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "--" when unknown
|
|
|
|
|
static void formatHaValue(const PageElement &e, const char *value, const LocalTime &t, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
char unit[ELEMENT_SUFFIX_SIZE + 2];
|
|
|
|
|
snprintf(unit, sizeof(unit), "%s", e.suffix);
|
|
|
|
|
char shown[64];
|
|
|
|
|
double v;
|
|
|
|
|
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
|
|
|
|
|
snprintf(shown, sizeof(shown), "--");
|
|
|
|
|
else if (e.show == HaShow::Map)
|
|
|
|
|
{
|
|
|
|
|
if (!showMapped(e, value, shown, sizeof(shown)))
|
|
|
|
|
snprintf(shown, sizeof(shown), "%s", value);
|
|
|
|
|
}
|
|
|
|
|
else if (e.show == HaShow::Time)
|
|
|
|
|
{
|
|
|
|
|
if (!showTimestamp(e, value, t, shown, sizeof(shown)))
|
|
|
|
|
snprintf(shown, sizeof(shown), "%s", value);
|
|
|
|
|
}
|
|
|
|
|
else if (e.show == HaShow::Duration && parseNumber(value, v))
|
|
|
|
|
showDuration(e, v, shown, sizeof(shown));
|
|
|
|
|
else if (e.show == HaShow::Number && parseNumber(value, v) &&
|
|
|
|
|
(e.decimals >= 0 || e.factor != 1 || e.auto_unit || e.absolute))
|
|
|
|
|
showNumber(e, v, shown, sizeof(shown), unit, sizeof(unit));
|
|
|
|
|
else
|
|
|
|
|
snprintf(shown, sizeof(shown), "%s", value); // not a number: as is
|
|
|
|
|
snprintf(out, size, "%s%s%s", e.text, shown, unit);
|
2026-10-15 19:43:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
// Seconds until the target; negative when it has passed
|
|
|
|
|
static int64_t secondsLeft(const PageElement &e, const LocalTime &t)
|
|
|
|
|
{
|
|
|
|
|
return (int64_t)e.target * 60 - ((int64_t)minutesSince2000(t) * 60 + t.second);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void formatCountdown(const PageElement &e, const LocalTime &t, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
int64_t left = secondsLeft(e, t);
|
|
|
|
|
if (left <= 0)
|
|
|
|
|
{
|
|
|
|
|
snprintf(out, size, "%s%s", e.text, e.suffix[0] ? e.suffix : "0");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
int lang = language(e);
|
|
|
|
|
long days = (long)(left / 86400), hours = (long)(left % 86400 / 3600), minutes = (long)(left % 3600 / 60),
|
|
|
|
|
seconds = (long)(left % 60);
|
|
|
|
|
char value[32];
|
|
|
|
|
switch (e.format)
|
|
|
|
|
{
|
|
|
|
|
case COUNTDOWN_DAYS:
|
|
|
|
|
{
|
|
|
|
|
// Calendar days: tomorrow is 1 day, whatever the time
|
|
|
|
|
long calendar = (long)(e.target / MINUTES_PER_DAY) - (long)(minutesSince2000(t) / MINUTES_PER_DAY);
|
|
|
|
|
snprintf(value, sizeof(value), "%ld %s", calendar, DAYS_WORD[lang][calendar != 1]);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case COUNTDOWN_DAYS_TIME:
|
|
|
|
|
if (days)
|
|
|
|
|
snprintf(value, sizeof(value), "%ldd %02ld:%02ld", days, hours, minutes);
|
|
|
|
|
else
|
|
|
|
|
snprintf(value, sizeof(value), "%02ld:%02ld", hours, minutes);
|
|
|
|
|
break;
|
|
|
|
|
case COUNTDOWN_SECONDS:
|
|
|
|
|
if (days)
|
|
|
|
|
snprintf(value, sizeof(value), "%ldd %02ld:%02ld:%02ld", days, hours, minutes, seconds);
|
|
|
|
|
else
|
|
|
|
|
snprintf(value, sizeof(value), "%02ld:%02ld:%02ld", hours, minutes, seconds);
|
|
|
|
|
break;
|
|
|
|
|
default: // smart: the largest unit
|
|
|
|
|
if (days)
|
|
|
|
|
snprintf(value, sizeof(value), "%ld %s", days, DAYS_WORD[lang][days != 1]);
|
|
|
|
|
else if (hours)
|
|
|
|
|
snprintf(value, sizeof(value), "%ld %s", hours, HOURS_WORD[lang][hours != 1]);
|
|
|
|
|
else if (minutes)
|
|
|
|
|
snprintf(value, sizeof(value), "%ld min", minutes);
|
|
|
|
|
else
|
|
|
|
|
snprintf(value, sizeof(value), "0:%02ld", seconds);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
snprintf(out, size, "%s%s", e.text, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void formatStopwatch(const PageElement &e, uint32_t ms, char *out, size_t size)
|
|
|
|
|
{
|
|
|
|
|
unsigned long s = ms / 1000, h = s / 3600, m = s % 3600 / 60, sec = s % 60;
|
|
|
|
|
char value[24];
|
|
|
|
|
if (e.format == STOPWATCH_H_MM)
|
|
|
|
|
snprintf(value, sizeof(value), "%lu:%02lu", h, m);
|
|
|
|
|
else if (e.format == STOPWATCH_H_MM_SS || h)
|
|
|
|
|
snprintf(value, sizeof(value), "%lu:%02lu:%02lu", h, m, sec);
|
|
|
|
|
else
|
|
|
|
|
snprintf(value, sizeof(value), "%lu:%02lu", m, sec);
|
|
|
|
|
snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void elementText(const PageElement &e, const LocalTime &t, const ElementValues &values, char *out, size_t size)
|
2026-10-15 19:43:59 +02:00
|
|
|
{
|
|
|
|
|
switch (e.type)
|
|
|
|
|
{
|
|
|
|
|
case ElementType::Clock:
|
2026-10-15 21:01:14 +02:00
|
|
|
if (e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS)
|
|
|
|
|
snprintf(out, size, e.format == CLOCK_H_MM_SS ? "%d:%02d:%02d" : "%02d:%02d:%02d", t.hour, t.minute,
|
|
|
|
|
t.second);
|
|
|
|
|
else
|
|
|
|
|
snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute);
|
2026-10-15 19:43:59 +02:00
|
|
|
break;
|
|
|
|
|
case ElementType::Date:
|
|
|
|
|
formatDate(e, t, out, size);
|
|
|
|
|
break;
|
|
|
|
|
case ElementType::HaValue:
|
2026-10-15 21:50:19 +02:00
|
|
|
formatHaValue(e, values.ha_value, t, out, size);
|
2026-10-15 21:01:14 +02:00
|
|
|
break;
|
|
|
|
|
case ElementType::Countdown:
|
|
|
|
|
formatCountdown(e, t, out, size);
|
|
|
|
|
break;
|
|
|
|
|
case ElementType::Stopwatch:
|
|
|
|
|
formatStopwatch(e, values.stopwatch_ms, out, size);
|
|
|
|
|
break;
|
|
|
|
|
case ElementType::Image:
|
|
|
|
|
out[0] = '\0';
|
2026-10-15 19:43:59 +02:00
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
snprintf(out, size, "%s", e.text);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-15 21:01:14 +02:00
|
|
|
|
|
|
|
|
bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running)
|
|
|
|
|
{
|
|
|
|
|
switch (e.type)
|
|
|
|
|
{
|
|
|
|
|
case ElementType::Clock:
|
|
|
|
|
return e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS;
|
|
|
|
|
case ElementType::Stopwatch:
|
|
|
|
|
return stopwatch_running && e.format != STOPWATCH_H_MM;
|
|
|
|
|
case ElementType::Countdown:
|
|
|
|
|
{
|
|
|
|
|
int64_t left = secondsLeft(e, t);
|
|
|
|
|
if (left <= 0)
|
|
|
|
|
return false;
|
|
|
|
|
return e.format == COUNTDOWN_SECONDS || (e.format == COUNTDOWN_SMART && left < 60);
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|