Public Access
Add scrolling, countdown, stopwatch, images, seconds, multiplier and degree sign
- Text elements can scroll in a window at a set speed; clock, countdown and stopwatch can show seconds. Moving pages are sent as instant frames (new Nano command D1) and paced by the estimated flip time. - Countdown to a date and time (smart, days, days + time, with seconds), stopwatch (web UI, HA switch and reset button), image element (fixed or by Home Assistant state), multiplier for HA values, degree sign (char 127) in all fonts and the font converter. - Page format version 2; version 1 pages are converted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca8d78ea8a
commit
fc6fa1a180
38 files changed
+1370
-277
No files matched your search
+228
-29
@@ -1,24 +1,26 @@
|
||||
#include "logic/page.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.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;
|
||||
}
|
||||
|
||||
// Printable ASCII only: the fonts have no other characters
|
||||
// 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 > 126)
|
||||
if (*c < 32 || *c > DEGREE_SIGN)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -44,6 +46,23 @@ static bool validAttribute(const char *attribute)
|
||||
return true;
|
||||
}
|
||||
|
||||
static uint8_t formatCount(ElementType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const char *validatePage(const DynamicPage &page)
|
||||
{
|
||||
if (page.slot_count > PAGE_SLOTS)
|
||||
@@ -73,18 +92,32 @@ const char *validatePage(const DynamicPage &page)
|
||||
if (!terminated(e.font, sizeof(e.font)) || !validBitmapName(e.font))
|
||||
return "Invalid font name";
|
||||
if (!printable(e.text, sizeof(e.text)) || !printable(e.suffix, sizeof(e.suffix)))
|
||||
return "Texts can only contain plain characters (no accents), up to 32";
|
||||
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.type == ElementType::Clock && e.format >= CLOCK_FORMAT_COUNT)
|
||||
return "Unknown clock format";
|
||||
if (e.type == ElementType::Date && e.format >= DATE_FORMAT_COUNT)
|
||||
return "Unknown date format";
|
||||
if (e.format >= formatCount(e.type))
|
||||
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";
|
||||
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
|
||||
return "Home Assistant values need an entity id like sensor.outside_temperature";
|
||||
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;
|
||||
}
|
||||
@@ -100,6 +133,71 @@ const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day)
|
||||
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 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,10 +220,18 @@ 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 void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size)
|
||||
{
|
||||
int lang = e.language < Language::Count ? (int)e.language : 0;
|
||||
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)
|
||||
@@ -154,50 +260,143 @@ static void formatDate(const PageElement &e, const LocalTime &t, char *out, size
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded to the element's decimals when it is a number; "--" when unknown
|
||||
// Rounded to the element's decimals (after the multiplier) when it is a number; "--" when unknown
|
||||
static void formatHaValue(const PageElement &e, const char *value, char *out, size_t size)
|
||||
{
|
||||
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
|
||||
value = "--";
|
||||
char number[24];
|
||||
if (e.decimals >= 0 && strcmp(value, "--") != 0)
|
||||
char *end;
|
||||
double v = strtod(value, &end);
|
||||
if (strcmp(value, "--") != 0 && end != value && *end == '\0' && (e.decimals >= 0 || e.factor != 1))
|
||||
{
|
||||
char *end;
|
||||
double v = strtod(value, &end);
|
||||
if (end != value && *end == '\0')
|
||||
{
|
||||
v *= e.factor;
|
||||
if (e.decimals >= 0)
|
||||
snprintf(number, sizeof(number), "%.*f", e.decimals, v);
|
||||
if (strcmp(number, "-0") == 0 || strncmp(number, "-0.", 3) == 0)
|
||||
{
|
||||
// -0.0 after rounding: no minus sign
|
||||
bool zero = true;
|
||||
for (const char *c = number + 1; *c; c++)
|
||||
if (*c != '0' && *c != '.')
|
||||
zero = false;
|
||||
if (zero)
|
||||
memmove(number, number + 1, strlen(number));
|
||||
}
|
||||
value = number;
|
||||
}
|
||||
else
|
||||
snprintf(number, sizeof(number), "%.6g", v);
|
||||
// -0.0 after rounding: no minus sign
|
||||
if (number[0] == '-' && strspn(number + 1, "0.") == strlen(number + 1))
|
||||
memmove(number, number + 1, strlen(number));
|
||||
value = number;
|
||||
}
|
||||
snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
|
||||
}
|
||||
|
||||
void elementText(const PageElement &e, const LocalTime &t, const char *ha_value, char *out, size_t size)
|
||||
// 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:
|
||||
snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute);
|
||||
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, ha_value, out, size);
|
||||
formatHaValue(e, values.ha_value, 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:
|
||||
out[0] = '\0';
|
||||
break;
|
||||
default:
|
||||
snprintf(out, size, "%s", e.text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user