Files
Flipdot-display/src/esp32/logic/page.cpp
T
mangavalkandClaude Opus 5.5 948087077d Progress bars can show the percentage on top
The percentage (rounded down, in the element's font) is centred inside the
bar and its dots invert the bar, so it reads on the filled and the empty
part. It uses the bar's unused format and font fields, so the page format
stays the same. The Bad page shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:27:56 +02:00

826 lines
25 KiB
C++

#include "logic/page.h"
#include <ctype.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
static const uint16_t MINUTES_PER_DAY = 24 * 60;
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
static bool terminated(const char *text, size_t size)
{
return memchr(text, '\0', size) != nullptr;
}
// Plain ASCII and the degree sign 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 > DEGREE_SIGN)
return false;
return true;
}
bool expandEntity(const char *entity, const char *user, char *out, size_t out_size)
{
const char *at = strstr(entity, PLEX_USER_PLACEHOLDER);
int n;
if (at)
n = snprintf(out, out_size, "%.*s%s%s", (int)(at - entity), entity, user,
at + strlen(PLEX_USER_PLACEHOLDER));
else
n = snprintf(out, out_size, "%s", entity);
return n >= 0 && (size_t)n < out_size;
}
void plexUserSlug(const char *name, char *out, size_t out_size)
{
size_t n = 0;
bool gap = false;
for (const char *c = name; *c && n + 1 < out_size; c++)
{
if (isalnum((unsigned char)*c))
{
if (gap && n > 0 && n + 2 < out_size)
out[n++] = '_';
gap = false;
out[n++] = tolower((unsigned char)*c);
}
else
gap = true;
}
out[n] = '\0';
}
bool elementUsesHa(const PageElement &element)
{
switch (element.type)
{
case ElementType::HaValue:
return true;
case ElementType::Image:
case ElementType::Bar:
return element.entity[0] != '\0';
default:
return false;
}
}
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
{
for (int i = 0; i < page.slot_count; i++)
if (sameName(page.slots[i].bitmap, name))
return true;
for (int i = 0; i < page.element_count; i++)
{
const PageElement &e = page.elements[i];
if (e.type != ElementType::Image)
continue;
if (!e.entity[0])
{
if (sameName(e.text, name))
return true;
continue;
}
size_t start = strlen(e.text);
if (strncasecmp(name, e.text, start) != 0)
continue;
if (by_state ? name[start] != '\0' : strcasecmp(name + start, "default") == 0)
return true;
}
return false;
}
bool validEntityId(const char *entity)
{
if (entity == nullptr)
return false;
// Checked with a stand-in user name
if (strstr(entity, PLEX_USER_PLACEHOLDER))
{
char full[ELEMENT_ENTITY_SIZE];
return strlen(entity) < (size_t)ELEMENT_ENTITY_SIZE && !strstr(strstr(entity, "}") + 1, "{") &&
expandEntity(entity, "x", full, sizeof(full)) && validEntityId(full);
}
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 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;
}
static bool validAttribute(const char *attribute)
{
for (const char *c = attribute; *c; c++)
if (!isalnum((unsigned char)*c) && *c != '_' && *c != '.') // dots: a path, e.g. forecast.1.condition
return false;
return true;
}
static uint8_t formatCount(const PageElement &e)
{
switch (e.type)
{
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
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;
case ElementType::Bar:
return BAR_FORMAT_COUNT;
default:
return 255; // not used
}
}
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";
if (page.effect != PAGE_EFFECT_DEFAULT && page.effect >= PAGE_EFFECT_COUNT)
return "Unknown refresh effect";
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) and the degree sign, 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.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))
return "Unknown format";
if (e.day_offset < 0 || e.day_offset > DAY_OFFSET_MAX)
return "Date: 0-7 days after today";
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 0.05-30 dots per second";
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
return "Home Assistant values need an entity id like sensor.outside_temperature";
if (e.type == ElementType::Bar)
{
if (!validEntityId(e.entity))
return "Progress bars need an entity id like sensor.progress";
if (e.bar_width < 1 || e.bar_width > BITMAP_WIDTH || e.bar_height < 1 || e.bar_height > BITMAP_HEIGHT)
return "Progress bar: width 1-84, height 1-19 dots";
if (!isfinite(e.bar_max) || e.bar_max <= 0)
return "Progress bar: the full value must be a number above 0";
}
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)";
}
}
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;
}
// ---------------------------------------------------------------------------
// 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;
}
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;
}
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);
}
// ---------------------------------------------------------------------------
// 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"},
};
// 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;
}
static int daysInMonth(int year, int month)
{
static const int DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
bool leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
return month == 2 && leap ? 29 : DAYS[(month - 1 + 12) % 12];
}
LocalTime addDays(const LocalTime &time, int days)
{
LocalTime t = time;
if (t.month < 1 || t.month > 12)
return t;
for (int i = 0; i < days; i++)
{
t.weekday = (t.weekday + 1) % 7;
if (++t.day > daysInMonth(t.year, t.month))
{
t.day = 1;
if (++t.month > 12)
{
t.month = 1;
t.year++;
}
}
}
return t;
}
static void formatDate(const PageElement &e, const LocalTime &today, char *out, size_t size)
{
LocalTime t = addDays(today, e.day_offset);
int lang = language(e);
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;
}
}
static bool parseNumber(const char *text, double &v)
{
char *end;
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)
{
// 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;
}
}
}
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);
}
// 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)
{
switch (e.type)
{
case ElementType::Clock:
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);
break;
case ElementType::Date:
formatDate(e, t, out, size);
break;
case ElementType::HaValue:
formatHaValue(e, values.ha_value, t, out, size);
break;
case ElementType::Countdown:
formatCountdown(e, t, out, size);
break;
case ElementType::Stopwatch:
formatStopwatch(e, values.stopwatch_ms, out, size);
break;
case ElementType::Image:
case ElementType::Bar:
out[0] = '\0';
break;
default:
snprintf(out, size, "%s", e.text);
break;
}
}
int barFill(const PageElement &e, const char *ha_value, int fill_width)
{
double v;
if (!ha_value || !parseNumber(ha_value, v) || fill_width <= 0)
return 0;
double part = v * e.factor / e.bar_max;
if (!(part > 0)) // also NaN
return 0;
if (part >= 1)
return fill_width;
int n = (int)(part * fill_width + 0.5);
return n;
}
void barPercent(const PageElement &e, const char *ha_value, char *out, size_t size)
{
double v;
if (!ha_value || !parseNumber(ha_value, v))
{
snprintf(out, size, "--");
return;
}
double percent = v * e.factor / e.bar_max * 100;
// Rounded down, so 100% only shows when the bar is full; + 1e-9 keeps 0.29 x 100 at 29
int n = !(percent > 0) ? 0 : percent >= 100 ? 100 : (int)(percent + 1e-9);
snprintf(out, size, "%d%%", n);
}
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;
}
}