Public Access
Home Assistant requests in a task, progress bar, Plex page, bitmap cache
- net/ha_api: the HTTP requests run in a FreeRTOS task on core 0; loop()
only hands over copies through queues. The web UI's test button polls.
No more 3-4 s loop() stalls once a minute; timeout raised to 5 s.
- Progress bar page element (page format 4, v3 pages are converted)
- Plex user setting (settings v6): {plex_user} in entity ids
- Plex page (examples): watchers, play/pause icon, title, progress
- Bitmaps cached in RAM: a LittleFS read took ~100 ms on every render
and web preview; long JSON lists sent in 2 KB pieces
- Backup includes the refresh settings and the Plex user
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
090c2bf8ae
commit
490cefc360
42 files changed
+1074
-175
No files matched your search
@@ -25,10 +25,62 @@ static bool printable(const char *text, size_t size)
|
||||
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 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;
|
||||
@@ -125,6 +177,15 @@ const char *validatePage(const DynamicPage &page)
|
||||
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::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)
|
||||
@@ -645,6 +706,7 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
|
||||
formatStopwatch(e, values.stopwatch_ms, out, size);
|
||||
break;
|
||||
case ElementType::Image:
|
||||
case ElementType::Bar:
|
||||
out[0] = '\0';
|
||||
break;
|
||||
default:
|
||||
@@ -653,6 +715,20 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running)
|
||||
{
|
||||
switch (e.type)
|
||||
|
||||
+30
-2
@@ -7,7 +7,8 @@
|
||||
#include "logic/local_time.h"
|
||||
|
||||
// Dynamic pages: a background bitmap that depends on the time of day, with elements
|
||||
// (clock, date, Home Assistant value, text, countdown, stopwatch, image) drawn on top.
|
||||
// (clock, date, Home Assistant value, text, countdown, stopwatch, image, progress bar)
|
||||
// drawn on top.
|
||||
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
|
||||
// logic, tested in test/test_esp32.
|
||||
//
|
||||
@@ -32,6 +33,7 @@ enum class ElementType : uint8_t
|
||||
Countdown, // time left until a date and time
|
||||
Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it)
|
||||
Image, // a bitmap, fixed or chosen by a Home Assistant state
|
||||
Bar, // a progress bar filled by a Home Assistant number (e.g. 0-100 %)
|
||||
Count,
|
||||
};
|
||||
|
||||
@@ -164,6 +166,11 @@ struct PageElement
|
||||
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)
|
||||
// Added in page format 4
|
||||
uint8_t bar_width = 40; // Bar: size in dots, frame included; x is its left, middle or right
|
||||
uint8_t bar_height = 3;
|
||||
float bar_max = 100; // Bar: the value (after the multiplier) that fills it
|
||||
bool bar_frame = true; // Bar: a line around it, filled inside
|
||||
};
|
||||
|
||||
struct DynamicPage
|
||||
@@ -193,10 +200,31 @@ struct ElementValues
|
||||
void elementText(const PageElement &element, const LocalTime &time, const ElementValues &values, char *out,
|
||||
size_t out_size);
|
||||
|
||||
// Bar: how many dots of fill_width are filled for this Home Assistant value (value x
|
||||
// factor / bar_max, 0 to fill_width); 0 when the value is unknown or not a number
|
||||
int barFill(const PageElement &element, const char *ha_value, int fill_width);
|
||||
|
||||
// Whether the element's text shows seconds now (it then changes every second)
|
||||
bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool stopwatch_running);
|
||||
|
||||
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.')
|
||||
// Longest text of a Home Assistant value that is kept (longer ones are cut)
|
||||
const int HA_VALUE_SIZE = 128;
|
||||
|
||||
// "{plex_user}" in an entity id (page elements, rules) stands for the Plex user of the
|
||||
// settings, e.g. sensor.{plex_user}_state for Tautulli's sensors of that user
|
||||
#define PLEX_USER_PLACEHOLDER "{plex_user}"
|
||||
const int PLEX_USER_SIZE = 33;
|
||||
// The entity id with the placeholder replaced by user (out_size: ELEMENT_ENTITY_SIZE).
|
||||
// False when it doesn't fit.
|
||||
bool expandEntity(const char *entity, const char *user, char *out, size_t out_size);
|
||||
// A user name the way Home Assistant puts it in entity ids: "Manga Valk" -> "manga_valk"
|
||||
void plexUserSlug(const char *name, char *out, size_t out_size);
|
||||
|
||||
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
|
||||
bool elementUsesHa(const PageElement &element);
|
||||
|
||||
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
|
||||
// {plex_user} may be in them
|
||||
bool validEntityId(const char *entity);
|
||||
|
||||
// Home Assistant timestamps ("2026-10-15T21:07:00+00:00", "...Z", ".123", or a space
|
||||
|
||||
@@ -89,6 +89,38 @@ static void drawImage(const PageElement &e, PageResources &resources, uint8_t *b
|
||||
}
|
||||
}
|
||||
|
||||
// Bar: a frame (optional) filled from the left by the Home Assistant value
|
||||
static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bitmap, ElementBox &box)
|
||||
{
|
||||
int w = e.bar_width, h = e.bar_height;
|
||||
int left = e.x;
|
||||
if (e.align == Align::Center)
|
||||
left -= w / 2;
|
||||
else if (e.align == Align::Right)
|
||||
left -= w;
|
||||
box.x = left;
|
||||
box.y = e.y;
|
||||
box.width = w;
|
||||
box.height = h;
|
||||
if (e.overlay == Overlay::Box)
|
||||
clearBox(bitmap, left - 1, e.y - 1, w + 2, h + 2);
|
||||
|
||||
// With a frame the fill is inside it (a bar of 1 or 2 dots high has no room: no frame)
|
||||
bool frame = e.bar_frame && w >= 3 && h >= 3;
|
||||
int inner = frame ? 1 : 0;
|
||||
int fill = barFill(e, resources.haValue(e), w - 2 * inner);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
bool edge = frame && (x == 0 || y == 0 || x == w - 1 || y == h - 1);
|
||||
bool filled = !edge && x - inner < fill;
|
||||
if (!edge && !filled)
|
||||
continue;
|
||||
int bx = left + x, by = e.y + y;
|
||||
setBitmapPixel(bitmap, bx, by, e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true);
|
||||
}
|
||||
}
|
||||
|
||||
// A text element; returns the ms until it scrolls on (0: it doesn't scroll)
|
||||
static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources,
|
||||
uint8_t *bitmap, ElementBox &box)
|
||||
@@ -152,6 +184,8 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
|
||||
ElementBox box;
|
||||
if (e.type == ElementType::Image)
|
||||
drawImage(e, resources, bitmap, box);
|
||||
else if (e.type == ElementType::Bar)
|
||||
drawBar(e, resources, bitmap, box);
|
||||
else
|
||||
{
|
||||
ElementValues values;
|
||||
|
||||
@@ -41,7 +41,7 @@ const char *validateSettings(const Settings &s)
|
||||
const HomeAssistantSettings &ha = s.home_assistant;
|
||||
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
|
||||
!terminated(ha.password, sizeof(ha.password)) || !terminated(ha.api_url, sizeof(ha.api_url)) ||
|
||||
!terminated(ha.token, sizeof(ha.token)))
|
||||
!terminated(ha.token, sizeof(ha.token)) || !terminated(s.plex_user, sizeof(s.plex_user)))
|
||||
return "Home Assistant text too long";
|
||||
if (ha.port == 0)
|
||||
return "Invalid MQTT port";
|
||||
|
||||
@@ -83,6 +83,8 @@ struct Settings
|
||||
char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK;
|
||||
DriveSettings drive;
|
||||
RefreshSettings refresh;
|
||||
// Plex user name for {plex_user} in entity ids (Tautulli's sensors of that user)
|
||||
char plex_user[PLEX_USER_SIZE] = "";
|
||||
// New fields go here, at the end (see system/settings_store.cpp)
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user