#include "logic/page.h" #include #include #include #include #include #include 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; } }