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
+235
-78
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user