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

+235 -78
View File
@@ -3,14 +3,40 @@
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <vector>
#include "config.h"
#include "credentials.h"
// The requests run in their own task (HA_TASK_CORE), so a slow or unreachable Home
// Assistant never blocks loop(). Everything else stays in loop(): the task only gets a
// copy of what it needs and sends a copy of the answer back, through two queues of plain
// structs (no shared Strings, no locks).
struct Request
{
char url[sizeof(HomeAssistantSettings::api_url) + 16 + ELEMENT_ENTITY_SIZE];
char token[sizeof(HomeAssistantSettings::token)];
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
bool preview;
};
struct Response
{
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
char value[HA_VALUE_SIZE];
const char *error; // a string literal, nullptr when the value was read
bool preview;
};
struct Entry
{
String entity;
String entity; // with the placeholders filled in
String attribute;
String value;
bool known;
@@ -28,11 +54,104 @@ struct Entry
static String base_url;
static String token;
static char plex_user[PLEX_USER_SIZE] = "";
static std::vector<Entry> entries;
// Kept for the editor's previews, besides the watched ones
static const size_t MAX_ENTRIES = 24;
void haApiSetSettings(const HomeAssistantSettings &settings)
static QueueHandle_t requests = nullptr;
static QueueHandle_t responses = nullptr;
static bool poll_busy = false; // a poll request is with the task
// The web UI's test button: one at a time, the answer is kept until it is picked up
static bool preview_busy = false;
static bool preview_done = false;
static String preview_entity, preview_attribute, preview_value;
static const char *preview_error = nullptr;
static uint32_t preview_done_at = 0;
// ---------------------------------------------------------------------------
// The task
// ---------------------------------------------------------------------------
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer)
static const char *fetch(const Request &r, char *value, size_t value_size)
{
if (!WiFi.isConnected())
return "No WiFi";
HTTPClient http;
http.setConnectTimeout(HA_HTTP_TIMEOUT);
http.setTimeout(HA_HTTP_TIMEOUT);
if (!http.begin(r.url))
return "Invalid Home Assistant URL";
http.addHeader("Authorization", String("Bearer ") + r.token);
int code = http.GET();
if (code != 200)
{
http.end();
if (code == 401)
return "Home Assistant refused the token";
if (code == 404)
return "Unknown entity";
return code < 0 ? "Home Assistant not reachable" : "Unexpected answer from Home Assistant";
}
JsonDocument filter;
if (r.attribute[0])
filter["attributes"][r.attribute] = true;
else
filter["state"] = true;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString(), DeserializationOption::Filter(filter));
http.end();
if (error)
return "Invalid answer from Home Assistant";
JsonVariantConst v = doc["state"];
if (r.attribute[0])
v = doc["attributes"][r.attribute];
if (v.isNull())
return "Unknown attribute";
String text;
if (v.is<const char *>())
text = v.as<const char *>();
else
serializeJson(v, text); // numbers, true/false
text.replace("\xC2\xB0", String(DEGREE_SIGN)); // UTF-8 degree sign: the fonts have it as 127
strlcpy(value, text.c_str(), value_size);
return nullptr;
}
static void haTask(void *)
{
static Request r; // static: keeps the large structs off the task's stack
static Response res;
while (true)
{
if (xQueueReceive(requests, &r, portMAX_DELAY) != pdTRUE)
continue;
strlcpy(res.entity, r.entity, sizeof(res.entity));
strlcpy(res.attribute, r.attribute, sizeof(res.attribute));
res.value[0] = '\0';
res.preview = r.preview;
res.error = fetch(r, res.value, sizeof(res.value));
xQueueSend(responses, &res, portMAX_DELAY);
}
}
void haApiBegin()
{
requests = xQueueCreate(2, sizeof(Request));
responses = xQueueCreate(2, sizeof(Response));
xTaskCreatePinnedToCore(haTask, "ha_api", HA_TASK_STACK, nullptr, 1, nullptr, HA_TASK_CORE);
}
// ---------------------------------------------------------------------------
// loop() side
// ---------------------------------------------------------------------------
void haApiSetSettings(const HomeAssistantSettings &settings, const char *user)
{
if (settings.api_url[0])
base_url = settings.api_url;
@@ -41,11 +160,25 @@ void haApiSetSettings(const HomeAssistantSettings &settings)
while (base_url.endsWith("/"))
base_url.remove(base_url.length() - 1);
token = settings.token;
strlcpy(plex_user, user ? user : "", sizeof(plex_user));
for (Entry &e : entries)
e.next_poll = millis(); // read again with the new settings
}
static Entry *find(const char *entity, const char *attribute)
// The entity with the placeholders filled in; false when that is not a valid entity id
// (also with {plex_user} while no user is set)
static bool expand(const char *entity, String &out)
{
if (!plex_user[0] && strstr(entity, PLEX_USER_PLACEHOLDER))
return false;
char full[ELEMENT_ENTITY_SIZE];
if (!expandEntity(entity, plex_user, full, sizeof(full)) || !validEntityId(full))
return false;
out = full;
return true;
}
static Entry *find(const String &entity, const char *attribute)
{
for (Entry &e : entries)
if (e.entity == entity && e.attribute == attribute)
@@ -53,7 +186,7 @@ static Entry *find(const char *entity, const char *attribute)
return nullptr;
}
static Entry &add(const char *entity, const char *attribute)
static Entry &add(const String &entity, const char *attribute)
{
Entry *e = find(entity, attribute);
if (e)
@@ -78,10 +211,10 @@ void haApiWatch(const DynamicPage *page)
for (int i = 0; i < page->element_count; i++)
{
const PageElement &element = page->elements[i];
bool uses_state = element.type == ElementType::HaValue || element.type == ElementType::Image;
if (!uses_state || !validEntityId(element.entity))
String entity;
if (!elementUsesHa(element) || !expand(element.entity, entity))
continue;
Entry &e = add(element.entity, element.attribute);
Entry &e = add(entity, element.attribute);
if (!e.watched())
e.next_poll = millis();
e.page_watched = true;
@@ -97,7 +230,10 @@ void haApiWatchRules(const PageSchedule *schedule)
for (int i = 0; i < schedule->rule_count; i++)
{
const TriggerRule &rule = schedule->rules[i];
Entry &e = add(rule.entity, rule.attribute);
String entity;
if (!expand(rule.entity, entity))
continue;
Entry &e = add(entity, rule.attribute);
if (!e.watched())
e.next_poll = millis();
uint32_t ms = rule.interval * 1000UL;
@@ -106,104 +242,125 @@ void haApiWatchRules(const PageSchedule *schedule)
}
}
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer)
static const char *fetch(const String &entity, const String &attribute, String &value)
static bool send(const String &entity, const String &attribute, bool preview)
{
if (!token.length())
return "No Home Assistant token set (Home Assistant tab)";
if (!WiFi.isConnected())
return "No WiFi";
static Request r;
snprintf(r.url, sizeof(r.url), "%s/api/states/%s", base_url.c_str(), entity.c_str());
strlcpy(r.token, token.c_str(), sizeof(r.token));
strlcpy(r.entity, entity.c_str(), sizeof(r.entity));
strlcpy(r.attribute, attribute.c_str(), sizeof(r.attribute));
r.preview = preview;
// The test button goes first; never wait here (loop() must not block)
return (preview ? xQueueSendToFront(requests, &r, 0) : xQueueSend(requests, &r, 0)) == pdTRUE;
}
HTTPClient http;
http.setConnectTimeout(HA_HTTP_TIMEOUT);
http.setTimeout(HA_HTTP_TIMEOUT);
if (!http.begin(base_url + "/api/states/" + entity))
return "Invalid Home Assistant URL";
http.addHeader("Authorization", "Bearer " + token);
int code = http.GET();
if (code != 200)
// An answer from the task; true when a value of the shown page changed
static bool receive(const Response &res)
{
if (res.preview)
{
http.end();
if (code == 401)
return "Home Assistant refused the token";
if (code == 404)
return "Unknown entity";
return code < 0 ? "Home Assistant not reachable" : "Unexpected answer from Home Assistant";
preview_busy = false;
preview_done = true;
preview_done_at = millis();
preview_entity = res.entity;
preview_attribute = res.attribute;
preview_error = res.error;
preview_value = res.value;
if (!res.error)
{
Entry &e = add(res.entity, res.attribute);
e.value = res.value;
e.known = true;
}
return false;
}
JsonDocument filter;
if (attribute.length())
filter["attributes"][attribute] = true;
else
filter["state"] = true;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString(), DeserializationOption::Filter(filter));
http.end();
if (error)
return "Invalid answer from Home Assistant";
JsonVariantConst v = doc["state"];
if (attribute.length())
v = doc["attributes"][attribute];
if (v.isNull())
return "Unknown attribute";
if (v.is<const char *>())
value = v.as<const char *>();
else
poll_busy = false;
Entry *e = find(res.entity, res.attribute);
if (!e)
return false; // no longer kept
if (res.error)
{
value = "";
serializeJson(v, value); // numbers, true/false
// Shown as "--" until it can be read again. Read less often when Home Assistant
// is down.
Serial.printf("Home Assistant %s: %s\n", res.entity, res.error);
if (e->interval() < HA_ERROR_INTERVAL)
e->next_poll = millis() + HA_ERROR_INTERVAL;
bool changed = e->known && e->page_watched;
e->known = false;
return changed;
}
value.replace("\xC2\xB0", String(DEGREE_SIGN)); // UTF-8 degree sign: the fonts have it as 127
return nullptr;
bool changed = (!e->known || e->value != res.value) && e->page_watched;
e->value = res.value;
e->known = true;
return changed;
}
bool haApiLoop(bool wifi_connected)
{
if (!wifi_connected || !token.length())
if (!requests)
return false;
bool changed = false;
static Response res;
while (xQueueReceive(responses, &res, 0) == pdTRUE)
changed |= receive(res);
if (!wifi_connected || !token.length() || poll_busy)
return changed;
uint32_t now = millis();
for (Entry &e : entries)
{
if (!e.watched() || (int32_t)(now - e.next_poll) < 0)
continue;
e.next_poll = now + e.interval();
String value;
const char *error = fetch(e.entity, e.attribute, value);
if (error)
if (send(e.entity, e.attribute, false))
{
// Shown as "--" until it can be read again. Read less often, so a Home
// Assistant that is down doesn't block loop() all the time.
Serial.printf("Home Assistant %s: %s\n", e.entity.c_str(), error);
if (e.interval() < HA_ERROR_INTERVAL)
e.next_poll = now + HA_ERROR_INTERVAL;
bool changed = e.known && e.page_watched;
e.known = false;
return changed;
e.next_poll = now + e.interval();
poll_busy = true;
}
bool changed = (!e.known || e.value != value) && e.page_watched;
e.value = value;
e.known = true;
return changed; // one entity per loop()
break; // one request at a time
}
return false;
return changed;
}
const char *haApiValue(const char *entity, const char *attribute)
{
Entry *e = find(entity, attribute);
String full;
if (!expand(entity, full))
return nullptr;
Entry *e = find(full, attribute);
return e && e->known ? e->value.c_str() : nullptr;
}
const char *haApiFetchNow(const char *entity, const char *attribute, String &value)
const char *haApiPreview(const char *entity, const char *attribute, String &value, bool &pending)
{
if (!validEntityId(entity))
pending = false;
String full;
if (!plex_user[0] && strstr(entity, PLEX_USER_PLACEHOLDER))
return "Set the Plex user first (Home Assistant tab)";
if (!expand(entity, full))
return "Invalid entity id";
const char *error = fetch(entity, attribute, value);
if (error)
return error;
Entry &e = add(entity, attribute);
e.value = value;
e.known = true;
if (!token.length())
return "No Home Assistant token set (Home Assistant tab)";
if (!requests)
return "Home Assistant not started";
// The answer to an earlier call for the same value (picked up within a few seconds)
if (preview_done && preview_entity == full && preview_attribute == attribute &&
millis() - preview_done_at < HA_PREVIEW_KEEP)
{
preview_done = false;
if (preview_error)
return preview_error;
value = preview_value;
return nullptr;
}
if (!preview_busy)
{
if (!send(full, attribute, true))
return "Home Assistant busy, try again";
preview_busy = true;
preview_done = false;
}
pending = true;
return nullptr;
}
+16 -8
View File
@@ -9,11 +9,17 @@
// Values of Home Assistant entities for the dynamic pages, read over Home Assistant's REST
// API (GET <url>/api/states/<entity> with a long-lived access token). Only the entities of
// the shown page (every HA_POLL_INTERVAL) and of the trigger rules (their own interval) are
// polled, one per loop() so loop() never blocks longer than about 2 x HA_HTTP_TIMEOUT.
// Plain http only.
// polled, one at a time. The requests run in a task of their own (haApiBegin()), so
// loop() never waits for Home Assistant; everything else here is called from loop().
// Plain http only. Entity ids may contain {plex_user} (logic/page.h).
// URL and token; empty URL: http://<MQTT broker>:8123
void haApiSetSettings(const HomeAssistantSettings &settings);
// Starts the request task (in setup())
void haApiBegin();
// URL and token (empty URL: http://<MQTT broker>:8123) and the Plex user for {plex_user}
// (already in entity id form, see plexUserSlug()). Watch the page and the rules again
// after a change of the user.
void haApiSetSettings(const HomeAssistantSettings &settings, const char *plex_user);
// The page whose entities are polled (nullptr: none). They are read right away.
void haApiWatch(const DynamicPage *page);
@@ -21,12 +27,14 @@ void haApiWatch(const DynamicPage *page);
// The entities of the trigger rules, each read at its rule's interval (nullptr: none)
void haApiWatchRules(const PageSchedule *schedule);
// Polls one entity that is due. Returns true when a value of the shown page changed (redraw).
// After an error an entity is read again after HA_ERROR_INTERVAL at the soonest.
// Hands answers over and starts the next due request. Returns true when a value of the
// shown page changed (redraw). After an error an entity is read again after
// HA_ERROR_INTERVAL at the soonest.
bool haApiLoop(bool wifi_connected);
// The last value read for an entity (attribute empty: the state); nullptr when unknown
const char *haApiValue(const char *entity, const char *attribute);
// Reads one value now (the web UI's test button) and keeps it. Returns nullptr or an error.
const char *haApiFetchNow(const char *entity, const char *attribute, String &value);
// The web UI's test button: reads one value in the background. pending: not there yet,
// ask again (the answer is kept for HA_PREVIEW_KEEP). Returns nullptr or an error.
const char *haApiPreview(const char *entity, const char *attribute, String &value, bool &pending);