Public Access
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:
1 parent
93b9bec69a
commit
87e242bf89
11 files changed
+601
-30
No files matched your search
@@ -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
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user