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

+139
View File
@@ -2029,6 +2029,141 @@ void test_frame_check_periodic_resync()
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs); // not counted as a repair
}
static int countOn(const uint8_t *b)
{
int n = 0;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
n += bitmapPixel(b, x, y);
return n;
}
static DynamicPage barPage()
{
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Bar;
strcpy(e.entity, "sensor.{plex_user}_progress");
e.x = 10;
e.y = 14;
e.bar_width = 22; // 20 inside the frame
e.bar_height = 4;
return page;
}
void test_bar_fill()
{
PageElement e = barPage().elements[0];
TEST_ASSERT_EQUAL(0, barFill(e, nullptr, 20));
TEST_ASSERT_EQUAL(0, barFill(e, "unavailable", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "-5", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "0", 20));
TEST_ASSERT_EQUAL(10, barFill(e, "50", 20));
TEST_ASSERT_EQUAL(7, barFill(e, "33.3", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "100", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "250", 20));
e.bar_max = 1;
e.factor = 0.01f; // 0-100 as 0-1
TEST_ASSERT_EQUAL(5, barFill(e, "25", 20));
e.bar_max = 7200;
e.factor = 1;
TEST_ASSERT_EQUAL(10, barFill(e, "3600", 20)); // e.g. seconds of a 2 hour film
}
void test_bar_render()
{
FakeResources res;
DynamicPage page = barPage();
TEST_ASSERT_NULL(validatePage(page));
uint8_t b[BITMAP_BYTES];
ElementBox box;
res.value = "50";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(10, box.x);
TEST_ASSERT_EQUAL(14, box.y);
TEST_ASSERT_EQUAL(22, box.width);
TEST_ASSERT_EQUAL(4, box.height);
// Frame: 2 x 22 + 2 x 2 dots; inside 10 of 20 columns x 2 rows
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2 + 10 * 2, countOn(b));
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 14)); // corner
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 15)); // first filled dot
TEST_ASSERT_TRUE(bitmapPixel(b, 20, 16)); // last filled dot
TEST_ASSERT_FALSE(bitmapPixel(b, 21, 16)); // empty half
res.value = nullptr; // unknown: only the frame
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2, countOn(b));
// Without frame, right aligned, inverted over a full background
PageElement &e = page.elements[0];
e.bar_frame = false;
e.align = Align::Right;
e.x = 84;
e.overlay = Overlay::Invert;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "Full");
res.value = "100";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(62, box.x);
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT - 22 * 4, countOn(b));
TEST_ASSERT_FALSE(bitmapPixel(b, 83, 17));
TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17));
}
void test_bar_validation()
{
DynamicPage page = barPage();
page.elements[0].entity[0] = '\0';
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_width = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_height = 20;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = NAN;
TEST_ASSERT_NOT_NULL(validatePage(page));
TEST_ASSERT_TRUE(elementUsesHa(barPage().elements[0]));
}
void test_plex_user_placeholder()
{
char out[ELEMENT_ENTITY_SIZE];
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}_state", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.mangavalk_state", out);
TEST_ASSERT_TRUE(expandEntity("sensor.tautulli_watching", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.tautulli_watching", out);
// No user set: not a valid entity id, so nothing is read
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}", "", out, sizeof(out)));
TEST_ASSERT_FALSE(validEntityId(out));
// Too long once filled in
char user[PLEX_USER_SIZE];
memset(user, 'a', sizeof(user) - 1);
user[sizeof(user) - 1] = '\0';
TEST_ASSERT_FALSE(expandEntity("sensor.{plex_user}_a_long_name_that_is_too_long", user, out, sizeof(out)));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}_state"));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{plex_user}{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{other}_state"));
TEST_ASSERT_FALSE(validEntityId("{plex_user}")); // no domain
plexUserSlug("MangaValk", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("mangavalk", user);
plexUserSlug(" Manga Valk-2 ", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("manga_valk_2", user);
plexUserSlug("", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("", user);
char small[5];
plexUserSlug("ab cd ef", small, sizeof(small));
TEST_ASSERT_EQUAL_STRING("ab_c", small);
}
int main()
{
UNITY_BEGIN();
@@ -2158,6 +2293,10 @@ int main()
RUN_TEST(test_frame_check_no_reply);
RUN_TEST(test_frame_check_errors_and_restarts);
RUN_TEST(test_frame_check_periodic_resync);
RUN_TEST(test_bar_fill);
RUN_TEST(test_bar_render);
RUN_TEST(test_bar_validation);
RUN_TEST(test_plex_user_placeholder);
return UNITY_END();
}