Add conversions for Home Assistant values: duration, time, own texts, auto unit

- Show as number (multiplier, decimals, auto unit W -> kW -> MW, without
  minus sign), duration (seconds/minutes/hours as 2h 21m, 2:21:00, 2:21 or
  smart), time (HA timestamps in local time, with weekday or date, or
  relative: over 34 min / 12 min geleden) or own texts per state.
- Page format 3; version 2 pages are converted. Version 2.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:50:19 +02:00
1 parent 93b9bec69a
commit 87e242bf89
11 files changed
+601 -30

No files matched your search

+1 -1
View File
@@ -95,7 +95,7 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
| Tab | What you can do |
|-----|-----------------|
| **Show** | Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05`, `8:05`, optionally with seconds), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after, multiplier, decimals), text, countdown to a date and time (smart `12 dagen` / `5 uur` / `34 min` / `0:45`, days, `12d 04:05`, with seconds; a text when it has passed), stopwatch (start, stop and reset in the Show tab, the editor and Home Assistant) and image (a bitmap at a position, fixed or chosen by a Home Assistant state, e.g. `weather ` + `cloudy`). Texts can scroll in a window at a chosen speed, and can contain the degree sign. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts it. |
| **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05`, `8:05`, optionally with seconds), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after; shown as a number with multiplier, decimals, auto unit `1257 W` → `1.3 kW` and optionally without minus sign, as a duration `2h 21m` / `2:21:00` / `2:21` / `2 uur`, as a time from an HA timestamp `23:07` / `do 23:07` / `15-10 23:07` / `over 34 min`, or with own texts per state `printing=Bezig, standby=Klaar`), text, countdown to a date and time (smart `12 dagen` / `5 uur` / `34 min` / `0:45`, days, `12d 04:05`, with seconds; a text when it has passed), stopwatch (start, stop and reset in the Show tab, the editor and Home Assistant) and image (a bitmap at a position, fixed or chosen by a Home Assistant state, e.g. `weather ` + `cloudy`). Texts can scroll in a window at a chosen speed, and can contain the degree sign. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts it. |
| **Schedule** | What is shown when, every day: blocks from a start time until the next block, each with up to 6 pages or bitmaps that rotate (each for its own time, 5 s to 1 hour), switching with the refresh effect or instantly. Rules: a Home Assistant state (entity or attribute, `=`, `is not`, `above`, `below`) that shows a page while it is true, adds it to the rotation while it is true, or shows it for a while when it becomes true; each rule is read at its own interval (2-600 s). A page chosen by hand (Show tab, HA "Mode") is shown until the next block starts; "Back to schedule" ends it earlier. The display schedule (Settings) still decides when the display is on. |
| **Stopwatch** | Start, stop and reset the stopwatch that pages can show. |
| **Bitmaps** | All bitmaps with a preview (the ones that pages or rules use in their own subsection): show one as the page, send it live, open it in the editor, delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be deleted, nor can a bitmap that a page uses as background. |
+10
View File
@@ -388,6 +388,16 @@ bool appLocalTime(LocalTime &time)
struct timeval tv;
gettimeofday(&tv, nullptr);
time.millisecond = tv.tv_usec / 1000;
// Local time - UTC (summer time included), for Home Assistant's UTC timestamps
struct tm utc = {};
time_t seconds = tv.tv_sec;
gmtime_r(&seconds, &utc);
int day_diff = now.tm_yday - utc.tm_yday;
if (day_diff > 1) // around new year
day_diff = -1;
else if (day_diff < -1)
day_diff = 1;
time.utc_offset = day_diff * 24 * 60 + (now.tm_hour - utc.tm_hour) * 60 + (now.tm_min - utc.tm_min);
return true;
}
+1 -1
View File
@@ -105,7 +105,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.6.0"
#define FIRMWARE_VERSION "2.7.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+87 -2
View File
@@ -8,7 +8,7 @@
static const char *DIR = "/pages";
static const char *EXTENSION = ".pg";
// Increase when DynamicPage changes; older files are ignored unless converted below
static const uint16_t VERSION = 2;
static const uint16_t VERSION = 3;
struct StoredPage
{
@@ -18,7 +18,7 @@ struct StoredPage
};
// Version 1 (before scrolling, countdown, stopwatch, images and the multiplier): frozen
// copy of the layout, converted when loaded. Saving writes version 2.
// copy of the layout, converted when loaded. Saving writes the current version.
namespace v1
{
struct Element
@@ -79,6 +79,79 @@ static void convert(const Page &old, DynamicPage &page)
}
} // namespace v1
// Version 2 (before the Home Assistant value conversions): the elements lacked the fields
// from `show` on. Frozen copy, converted when loaded.
namespace v2
{
struct Element
{
ElementType type;
int16_t x, y;
Align align;
Overlay overlay;
char font[PAGE_NAME_SIZE];
uint8_t format;
Language language;
int8_t decimals;
char text[ELEMENT_TEXT_SIZE];
char suffix[ELEMENT_SUFFIX_SIZE];
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
float factor;
uint8_t scroll_width;
uint8_t scroll_speed;
uint32_t target;
};
struct Page
{
uint8_t slot_count;
PageSlot slots[PAGE_SLOTS];
uint8_t element_count;
Element elements[PAGE_ELEMENTS];
};
struct Stored
{
uint16_t version;
uint16_t size;
Page page;
};
static void convert(const Page &old, DynamicPage &page)
{
page = DynamicPage();
page.slot_count = old.slot_count;
memcpy(page.slots, old.slots, sizeof(page.slots));
page.element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++)
{
const Element &o = old.elements[i];
PageElement &e = page.elements[i];
e.type = o.type;
e.x = o.x;
e.y = o.y;
e.align = o.align;
e.overlay = o.overlay;
memcpy(e.font, o.font, sizeof(e.font));
e.format = o.format;
e.language = o.language;
e.decimals = o.decimals;
memcpy(e.text, o.text, sizeof(e.text));
memcpy(e.suffix, o.suffix, sizeof(e.suffix));
memcpy(e.entity, o.entity, sizeof(e.entity));
memcpy(e.attribute, o.attribute, sizeof(e.attribute));
e.factor = o.factor;
e.scroll_width = o.scroll_width;
e.scroll_speed = o.scroll_speed;
e.target = o.target;
}
}
} // namespace v2
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(StoredPage),
"the versions are told apart by their size");
static uint32_t revision = 0;
static String path(const char *name)
@@ -137,6 +210,18 @@ bool loadPage(const char *name, DynamicPage &page)
page = stored->page;
delete stored;
}
else if (size == sizeof(v2::Stored))
{
v2::Stored *stored = new v2::Stored;
ok = file.read((uint8_t *)stored, sizeof(v2::Stored)) == sizeof(v2::Stored) && stored->version == 2 &&
stored->size == sizeof(v2::Page);
if (ok)
{
v2::convert(stored->page, page);
ok = validatePage(page) == nullptr;
}
delete stored;
}
else if (size == sizeof(v1::Stored))
{
v1::Stored *stored = new v1::Stored;
+1
View File
@@ -11,4 +11,5 @@ struct LocalTime
int minute = 0;
int second = 0;
int millisecond = 0;
int utc_offset = 0; // minutes: local time - UTC (for Home Assistant timestamps)
};
+293 -21
View File
@@ -38,6 +38,14 @@ bool validEntityId(const char *entity)
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++)
@@ -46,10 +54,16 @@ static bool validAttribute(const char *attribute)
return true;
}
static uint8_t formatCount(ElementType type)
static uint8_t formatCount(const PageElement &e)
{
switch (type)
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:
@@ -98,7 +112,11 @@ const char *validatePage(const DynamicPage &page)
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.format >= formatCount(e.type))
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 (!isfinite(e.factor) || e.factor == 0)
return "The multiplier must be a number other than 0";
@@ -171,6 +189,40 @@ uint32_t minutesSince2000(const LocalTime &t)
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)
@@ -260,27 +312,247 @@ static void formatDate(const PageElement &e, const LocalTime &t, char *out, size
}
}
// 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)
static bool parseNumber(const char *text, double &v)
{
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
value = "--";
char number[24];
char *end;
double v = strtod(value, &end);
if (strcmp(value, "--") != 0 && end != value && *end == '\0' && (e.decimals >= 0 || e.factor != 1))
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)
{
v *= e.factor;
if (e.decimals >= 0)
snprintf(number, sizeof(number), "%.*f", e.decimals, v);
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;
// 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;
}
}
}
snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
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
@@ -364,7 +636,7 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
formatDate(e, t, out, size);
break;
case ElementType::HaValue:
formatHaValue(e, values.ha_value, out, size);
formatHaValue(e, values.ha_value, t, out, size);
break;
case ElementType::Countdown:
formatCountdown(e, t, out, size);
+42 -1
View File
@@ -20,6 +20,7 @@ const int ELEMENT_TEXT_SIZE = 33;
const int ELEMENT_SUFFIX_SIZE = 17;
const int ELEMENT_ENTITY_SIZE = 65;
const int ELEMENT_ATTRIBUTE_SIZE = 33;
const int ELEMENT_MAP_SIZE = 64;
const char DEGREE_SIGN = 127;
enum class ElementType : uint8_t
@@ -89,6 +90,36 @@ const uint8_t STOPWATCH_H_MM = 1; // 0:04 (hours and minutes)
const uint8_t STOPWATCH_H_MM_SS = 2; // 0:04:05
const uint8_t STOPWATCH_FORMAT_COUNT = 3;
// How a Home Assistant value is shown
enum class HaShow : uint8_t
{
Number, // multiplier, decimals, optionally auto unit (1257 W -> 1.3 kW) and no minus sign
Duration, // a number of seconds, minutes or hours: 2h 21m, 2:21:00, 2:21, 2 uur
Time, // a timestamp (ISO, e.g. 2026-10-15T21:07:00+00:00) in local time: 23:07, over 34 min
Map, // states replaced by own texts: "printing=Bezig, standby=Klaar"
Count,
};
// Duration: the unit of the value
const uint8_t UNIT_SECONDS = 0;
const uint8_t UNIT_MINUTES = 1;
const uint8_t UNIT_HOURS = 2;
const uint8_t UNIT_COUNT = 3;
// Duration formats
const uint8_t DURATION_PARTS = 0; // 2d 3h 15m (only the parts that are not 0)
const uint8_t DURATION_H_MM_SS = 1; // 2:15:00
const uint8_t DURATION_H_MM = 2; // 2:15
const uint8_t DURATION_SMART = 3; // the largest unit: 2 dagen, 5 uur, 34 min, 45 s
const uint8_t DURATION_FORMAT_COUNT = 4;
// Time (timestamp) formats
const uint8_t TIMESTAMP_H_MM = 0; // 23:07
const uint8_t TIMESTAMP_WEEKDAY_H_MM = 1; // do 23:07 / Thu 23:07
const uint8_t TIMESTAMP_DATE_H_MM = 2; // 15-10 23:07
const uint8_t TIMESTAMP_RELATIVE = 3; // over 34 min / 12 min geleden / nu
const uint8_t TIMESTAMP_FORMAT_COUNT = 4;
// Home Assistant values: decimals as received
const int8_t DECIMALS_AS_IS = -1;
const int8_t DECIMALS_MAX = 4;
@@ -113,7 +144,7 @@ struct PageElement
Align align = Align::Left;
Overlay overlay = Overlay::OnTop;
char font[PAGE_NAME_SIZE] = "Normal";
uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_* or STOPWATCH_*
uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_*, STOPWATCH_*, HaValue: DURATION_* or TIMESTAMP_*
Language language = Language::Dutch;
int8_t decimals = DECIMALS_AS_IS;
// Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the
@@ -127,6 +158,12 @@ struct PageElement
uint8_t scroll_width = 0; // texts: 0, or the width of a window the text scrolls in
uint8_t scroll_speed = 8; // dots per second
uint32_t target = 0; // Countdown: minutes since 2000-01-01 00:00 (local time)
// Added in page format 3 (keep new fields at the end, see display/page_store.cpp)
HaShow show = HaShow::Number; // HaValue: how the value is shown
uint8_t input_unit = UNIT_SECONDS; // HaValue Duration: the unit of the value
bool auto_unit = false; // HaValue Number: the text after is the unit; 1257 W -> 1.3 kW
bool absolute = false; // HaValue Number: without minus sign
char map[ELEMENT_MAP_SIZE] = ""; // HaValue Map: "state=text, state=text" (states without case)
};
struct DynamicPage
@@ -162,6 +199,10 @@ bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.')
bool validEntityId(const char *entity);
// Home Assistant timestamps ("2026-10-15T21:07:00+00:00", "...Z", ".123", or a space
// instead of the T; without zone: UTC) -> seconds since 2000-01-01 00:00 UTC
bool parseTimestamp(const char *text, int64_t &seconds);
// Countdown targets: minutes since 2000-01-01 00:00 <-> "YYYY-MM-DDTHH:MM" (17 characters,
// the format of an HTML datetime-local input). Years 2000-2099.
bool parseTarget(const char *text, uint32_t &minutes);
+16
View File
@@ -9,6 +9,8 @@ static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown",
static const char *const ALIGNS[] = {"left", "center", "right"};
static const char *const OVERLAYS[] = {"top", "box", "invert"};
static const char *const LANGUAGES[] = {"nl", "en"};
static const char *const SHOWS[] = {"number", "duration", "time", "map"};
static const char *const UNITS[] = {"s", "min", "h"};
template <size_t N> static int indexOf(const char *const (&names)[N], const char *name)
{
@@ -62,6 +64,11 @@ void pageToJson(const DynamicPage &page, JsonObject out)
char target[17];
formatTarget(e.target, target);
o["target"] = String(target);
o["show"] = SHOWS[(int)e.show];
o["input_unit"] = UNITS[e.input_unit];
o["auto_unit"] = e.auto_unit;
o["absolute"] = e.absolute;
o["map"] = withDegrees(e.map);
}
}
@@ -114,6 +121,15 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
e.factor = o["factor"] | 1.0f;
e.scroll_width = constrain(o["scroll_width"] | 0, 0, 255);
e.scroll_speed = constrain(o["scroll_speed"] | 8, 0, 255);
int show = indexOf(SHOWS, o["show"] | "number"), unit = indexOf(UNITS, o["input_unit"] | "s");
if (show < 0 || unit < 0)
return "Unknown Home Assistant value setting";
e.show = (HaShow)show;
e.input_unit = unit;
e.auto_unit = o["auto_unit"] | false;
e.absolute = o["absolute"] | false;
if (!copyText(o["map"], e.map, sizeof(e.map)))
return "The list of texts is too long (63 characters)";
const char *target = o["target"] | "";
if (e.type == ElementType::Countdown && !parseTarget(target, e.target))
return "Countdown: enter a date and time";
+3 -1
View File
@@ -9,7 +9,9 @@
// elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right",
// overlay: "top"|"box"|"invert", font, format, language: "nl"|"en",
// decimals (-1 = as received), text, suffix, entity, attribute, factor,
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM")}]}
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM"),
// show: "number"|"duration"|"time"|"map", input_unit: "s"|"min"|"h",
// auto_unit, absolute, map}]}
// type also "countdown", "stopwatch", "image". Texts use the UTF-8 degree sign.
void pageToJson(const DynamicPage &page, JsonObject out);
+14 -3
View File
@@ -302,7 +302,7 @@ const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 Oc
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow()}}
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:''}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
@@ -310,6 +310,7 @@ function peClose(){pe=null;$('pe').style.display='none';$('t-pages').scrollIntoV
function peBuild(){peSlots();peElems()}
function field(label,input){return mk('label',{},label+' ',input)}
function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i}
function chk(o,k,label){const c=mk('input',{type:'checkbox',checked:!!o[k]});c.onchange=()=>{o[k]=c.checked;pePreview()};return mk('label',{},c,' '+label)}
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
@@ -329,8 +330,18 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
row(field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
field('Multiply by',inpf(e,'factor',{style:'width:6em'})),field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']])))}
const sh=e.show||'number',fmt=()=>{e.format=0;peElems()};
row(field('Show as',sel(e,'show',[['number','number'],['duration','duration'],['time','time / date'],['map','own texts']],fmt)),
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
if(sh=='number')row(field('Multiply by',inpf(e,'factor',{style:'width:6em'})),field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']])),
chk(e,'auto_unit','auto unit (the text after is the unit: 1257 W shows as 1.3 kW)'),chk(e,'absolute','without minus sign'));
if(sh=='duration')row(field('The value is in',sel(e,'input_unit',[['s','seconds'],['min','minutes'],['h','hours']])),
field('Format',sel(e,'format',[[0,'2h 21m'],[1,'2:21:00'],[2,'2:21'],[3,e.language=='en'?'smart: 2 hours':'smart: 2 uur']])),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
if(sh=='time')row(field('Format',sel(e,'format',[[0,'23:07'],[1,e.language=='en'?'Thu 23:07':'do 23:07'],[2,'15-10 23:07'],[3,e.language=='en'?'in 34 min / 12 min ago':'over 34 min / 12 min geleden']])),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)),mk('span',{className:'hint',textContent:'for timestamps like 2026-10-15T21:07:00+00:00'}));
if(sh=='map')row(field('Texts',inp(e,'map','text',{size:40,maxLength:63,placeholder:'printing=Bezig, standby=Klaar, paused=Pauze'})),
mk('span',{className:'hint',textContent:'state=text, ... (other states as they are)'}))}
if(e.type=='text')row(field('Text',inp(e,'text','text',{size:24,maxLength:32})));
if(e.type=='countdown'){row(field('Until',inp(e,'target','datetime-local')),field('Format',sel(e,'format',CDF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
+133
View File
@@ -1739,6 +1739,135 @@ void test_selector_add_to_rotation()
TEST_ASSERT_EQUAL_STRING("3D printer", sel2.update(600, CHOSEN_PAGE_SECONDS * 1000).page);
}
// ---------------------------------------------------------------------------
// Home Assistant value conversions
// ---------------------------------------------------------------------------
static PageElement haElement(HaShow show, uint8_t format = 0)
{
PageElement e;
e.type = ElementType::HaValue;
strcpy(e.entity, "sensor.x");
e.show = show;
e.format = format;
return e;
}
void test_ha_auto_unit_and_absolute()
{
PageElement e = haElement(HaShow::Number);
e.auto_unit = true;
strcpy(e.suffix, " W");
TEST_ASSERT_EQUAL_STRING("1.3 kW", textOf(e, "1257").c_str());
TEST_ASSERT_EQUAL_STRING("950 W", textOf(e, "950").c_str());
TEST_ASSERT_EQUAL_STRING("-2.5 kW", textOf(e, "-2500").c_str());
e.absolute = true;
TEST_ASSERT_EQUAL_STRING("2.5 kW", textOf(e, "-2500").c_str());
strcpy(e.suffix, "Wh");
e.decimals = 2;
TEST_ASSERT_EQUAL_STRING("26.30kWh", textOf(e, "26300").c_str());
strcpy(e.suffix, "kWh");
e.decimals = DECIMALS_AS_IS;
TEST_ASSERT_EQUAL_STRING("18.6MWh", textOf(e, "18621.326").c_str());
TEST_ASSERT_EQUAL_STRING("500.0Wh", textOf(e, "0.5").c_str()); // below 1: smaller unit
strcpy(e.suffix, "%"); // not a unit it knows
TEST_ASSERT_EQUAL_STRING("37%", textOf(e, "37").c_str());
}
void test_ha_duration()
{
PageElement e = haElement(HaShow::Duration, DURATION_PARTS);
TEST_ASSERT_EQUAL_STRING("2h 20m", textOf(e, "8452.2").c_str());
TEST_ASSERT_EQUAL_STRING("1d 1h 1m", textOf(e, "90061").c_str());
TEST_ASSERT_EQUAL_STRING("2d", textOf(e, "172800").c_str());
TEST_ASSERT_EQUAL_STRING("45s", textOf(e, "45").c_str());
e.input_unit = UNIT_MINUTES;
TEST_ASSERT_EQUAL_STRING("1h 30m", textOf(e, "90").c_str());
e.input_unit = UNIT_HOURS;
e.format = DURATION_H_MM_SS;
TEST_ASSERT_EQUAL_STRING("26:30:00", textOf(e, "26.5").c_str());
e.format = DURATION_H_MM;
TEST_ASSERT_EQUAL_STRING("26:30", textOf(e, "26.5").c_str());
e.input_unit = UNIT_SECONDS;
e.format = DURATION_SMART;
TEST_ASSERT_EQUAL_STRING("2 uur", textOf(e, "8452").c_str());
TEST_ASSERT_EQUAL_STRING("1 dag", textOf(e, "90000").c_str());
TEST_ASSERT_EQUAL_STRING("34 min", textOf(e, "2040").c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING("2 hours", textOf(e, "8452").c_str());
TEST_ASSERT_EQUAL_STRING("--", textOf(e, "unknown").c_str());
TEST_ASSERT_EQUAL_STRING("idle", textOf(e, "idle").c_str()); // not a number: as is
}
void test_ha_timestamps()
{
int64_t s = 0;
TEST_ASSERT_TRUE(parseTimestamp("2000-01-01T00:00:00+00:00", s));
TEST_ASSERT_EQUAL_INT64(0, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00+00:00", s));
int64_t a = s;
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T23:07:00+02:00", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00.515369Z", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15 21:07:00", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_FALSE(parseTimestamp("21:07", s));
TEST_ASSERT_FALSE(parseTimestamp("2026-10-15T21:07:00+0200x", s));
// textOf: Thursday 15 October 2026 08:05:00 local; CEST is UTC+2
PageElement e = haElement(HaShow::Time, TIMESTAMP_H_MM);
auto at = [&](const char *value) {
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.weekday = 4;
t.hour = 8;
t.minute = 5;
t.utc_offset = 120;
char out[64];
ElementValues values;
values.ha_value = value;
elementText(e, t, values, out, sizeof(out));
return std::string(out);
};
TEST_ASSERT_EQUAL_STRING("23:07", at("2026-10-15T21:07:00+00:00").c_str());
e.format = TIMESTAMP_WEEKDAY_H_MM;
TEST_ASSERT_EQUAL_STRING("vr 01:30", at("2026-10-15T23:30:00+00:00").c_str()); // the next day
e.format = TIMESTAMP_DATE_H_MM;
TEST_ASSERT_EQUAL_STRING("16-10 01:30", at("2026-10-15T23:30:00+00:00").c_str());
e.format = TIMESTAMP_RELATIVE;
TEST_ASSERT_EQUAL_STRING("over 34 min", at("2026-10-15T06:39:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("12 min geleden", at("2026-10-15T05:53:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("nu", at("2026-10-15T06:05:30+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("over 2 uur", at("2026-10-15T08:30:00+00:00").c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING("3 days ago", at("2026-10-12T06:00:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("soon", at("soon").c_str()); // not a timestamp: as is
}
void test_ha_text_map()
{
PageElement e = haElement(HaShow::Map);
strcpy(e.map, "printing=Bezig, standby = Klaar ,paused=Pauze,=leeg");
strcpy(e.text, "3D ");
TEST_ASSERT_EQUAL_STRING("3D Bezig", textOf(e, "printing").c_str());
TEST_ASSERT_EQUAL_STRING("3D Klaar", textOf(e, "Standby").c_str());
TEST_ASSERT_EQUAL_STRING("3D Pauze", textOf(e, "paused").c_str());
TEST_ASSERT_EQUAL_STRING("3D error", textOf(e, "error").c_str()); // not in the list: as is
TEST_ASSERT_EQUAL_STRING("3D --", textOf(e, "unavailable").c_str());
DynamicPage page;
page.element_count = 1;
page.elements[0] = e;
TEST_ASSERT_NULL(validatePage(page));
page.elements[0].show = HaShow::Count;
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[0] = haElement(HaShow::Duration, DURATION_FORMAT_COUNT);
TEST_ASSERT_NOT_NULL(validatePage(page));
}
int main()
{
UNITY_BEGIN();
@@ -1856,6 +1985,10 @@ int main()
RUN_TEST(test_selector_one_block_starts_again_each_day);
RUN_TEST(test_selector_take_over_rules);
RUN_TEST(test_selector_add_to_rotation);
RUN_TEST(test_ha_auto_unit_and_absolute);
RUN_TEST(test_ha_duration);
RUN_TEST(test_ha_timestamps);
RUN_TEST(test_ha_text_map);
return UNITY_END();
}