Public Access
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>
826 lines
25 KiB
C++
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;
|
|
}
|
|
}
|