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:
mangavalkandClaude Opus 5.5 committed 2026-10-16 16:17:23 +02:00
1 parent 090c2bf8ae
commit 490cefc360
42 files changed
+1074 -175

No files matched your search

+76
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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;
+1 -1
View File
@@ -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";
+2
View File
@@ -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)
};