Public Access
Add dynamic pages with clock, date, Home Assistant values and fonts
The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page has a background bitmap per time of day and up to 8 elements (clock, date, Home Assistant value, text), each with position, alignment, font and overlay. Pages are drawn on the ESP32, also for the web previews. - Pages tab in the web UI: page editor with drag and drop, font upload (TTF/OTF converted in the browser to the FDF1 dot font format) - Built-in fonts Small, Normal, Large and Full height (generated by tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning - Home Assistant values over the REST API (URL and token in the HA tab) - Settings version 4: evening settings removed (now time slots of the Clock page), old records are converted - HA Mode dropdown lists the dynamic pages and bitmaps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d3b084de1
commit
0411804a2f
49 files changed
+3058
-331
No files matched your search
@@ -0,0 +1,178 @@
|
||||
#include "net/ha_api.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
#include <vector>
|
||||
|
||||
#include "config.h"
|
||||
#include "credentials.h"
|
||||
|
||||
struct Entry
|
||||
{
|
||||
String entity;
|
||||
String attribute;
|
||||
String value;
|
||||
bool known;
|
||||
bool watched;
|
||||
uint32_t next_poll;
|
||||
};
|
||||
|
||||
static String base_url;
|
||||
static String token;
|
||||
static std::vector<Entry> entries;
|
||||
// Kept for the editor's previews, besides the watched ones
|
||||
static const size_t MAX_ENTRIES = 16;
|
||||
|
||||
void haApiSetSettings(const HomeAssistantSettings &settings)
|
||||
{
|
||||
if (settings.api_url[0])
|
||||
base_url = settings.api_url;
|
||||
else
|
||||
base_url = String("http://") + (settings.host[0] ? String(settings.host) : BROKER_ADDR.toString()) + ":8123";
|
||||
while (base_url.endsWith("/"))
|
||||
base_url.remove(base_url.length() - 1);
|
||||
token = settings.token;
|
||||
for (Entry &e : entries)
|
||||
e.next_poll = millis(); // read again with the new settings
|
||||
}
|
||||
|
||||
static Entry *find(const char *entity, const char *attribute)
|
||||
{
|
||||
for (Entry &e : entries)
|
||||
if (e.entity == entity && e.attribute == attribute)
|
||||
return &e;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static Entry &add(const char *entity, const char *attribute)
|
||||
{
|
||||
Entry *e = find(entity, attribute);
|
||||
if (e)
|
||||
return *e;
|
||||
if (entries.size() >= MAX_ENTRIES)
|
||||
for (auto it = entries.begin(); it != entries.end(); ++it)
|
||||
if (!it->watched)
|
||||
{
|
||||
entries.erase(it);
|
||||
break;
|
||||
}
|
||||
entries.push_back({entity, attribute, "", false, false, millis()});
|
||||
return entries.back();
|
||||
}
|
||||
|
||||
void haApiWatch(const DynamicPage *page)
|
||||
{
|
||||
for (Entry &e : entries)
|
||||
e.watched = false;
|
||||
if (!page)
|
||||
return;
|
||||
for (int i = 0; i < page->element_count; i++)
|
||||
{
|
||||
const PageElement &element = page->elements[i];
|
||||
if (element.type != ElementType::HaValue || !validEntityId(element.entity))
|
||||
continue;
|
||||
Entry &e = add(element.entity, element.attribute);
|
||||
if (!e.watched)
|
||||
e.next_poll = millis();
|
||||
e.watched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (!token.length())
|
||||
return "No Home Assistant token set (Home Assistant tab)";
|
||||
if (!WiFi.isConnected())
|
||||
return "No WiFi";
|
||||
|
||||
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)
|
||||
{
|
||||
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 (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
|
||||
{
|
||||
value = "";
|
||||
serializeJson(v, value); // numbers, true/false
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool haApiLoop(bool wifi_connected)
|
||||
{
|
||||
if (!wifi_connected || !token.length())
|
||||
return false;
|
||||
uint32_t now = millis();
|
||||
for (Entry &e : entries)
|
||||
{
|
||||
if (!e.watched || (int32_t)(now - e.next_poll) < 0)
|
||||
continue;
|
||||
e.next_poll = now + HA_POLL_INTERVAL;
|
||||
String value;
|
||||
const char *error = fetch(e.entity, e.attribute, value);
|
||||
if (error)
|
||||
{
|
||||
// Shown as "--" until it can be read again
|
||||
Serial.printf("Home Assistant %s: %s\n", e.entity.c_str(), error);
|
||||
bool changed = e.known;
|
||||
e.known = false;
|
||||
return changed;
|
||||
}
|
||||
bool changed = !e.known || e.value != value;
|
||||
e.value = value;
|
||||
e.known = true;
|
||||
return changed; // one entity per loop()
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *haApiValue(const char *entity, const char *attribute)
|
||||
{
|
||||
Entry *e = find(entity, attribute);
|
||||
return e && e->known ? e->value.c_str() : nullptr;
|
||||
}
|
||||
|
||||
const char *haApiFetchNow(const char *entity, const char *attribute, String &value)
|
||||
{
|
||||
if (!validEntityId(entity))
|
||||
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;
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "logic/page.h"
|
||||
#include "logic/settings.h"
|
||||
|
||||
// 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 are polled, every HA_POLL_INTERVAL, one per loop() so loop() never blocks
|
||||
// longer than about 2 x HA_HTTP_TIMEOUT. Plain http only.
|
||||
|
||||
// URL and token; empty URL: http://<MQTT broker>:8123
|
||||
void haApiSetSettings(const HomeAssistantSettings &settings);
|
||||
|
||||
// The page whose entities are polled (nullptr: none). They are read right away.
|
||||
void haApiWatch(const DynamicPage *page);
|
||||
|
||||
// Polls one entity that is due. Returns true when a watched value changed (redraw).
|
||||
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);
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "config.h"
|
||||
#include "credentials.h"
|
||||
#include "display/bitmap_store.h"
|
||||
#include "display/page_store.h"
|
||||
#include "net/ha_page_select.h"
|
||||
#include "system/boot_target.h"
|
||||
|
||||
@@ -68,35 +69,51 @@ static void onPowerCommand(bool state, HASwitch *sender)
|
||||
sender->setState(state);
|
||||
}
|
||||
|
||||
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
|
||||
// alphabetically. Rebuilt when bitmaps were added or deleted.
|
||||
static const std::vector<String> &bitmapNames()
|
||||
static bool lessName(const String &a, const String &b)
|
||||
{
|
||||
static std::vector<String> names;
|
||||
static bool loaded = false;
|
||||
static uint32_t revision = 0;
|
||||
if (loaded && revision == bitmapListRevision())
|
||||
return names;
|
||||
loaded = true;
|
||||
revision = bitmapListRevision();
|
||||
return strcasecmp(a.c_str(), b.c_str()) < 0;
|
||||
}
|
||||
|
||||
std::vector<String> uploaded;
|
||||
names.clear();
|
||||
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
|
||||
// alphabetically
|
||||
static std::vector<String> bitmapNames()
|
||||
{
|
||||
std::vector<String> names, uploaded;
|
||||
for (const BitmapInfo &b : listBitmaps())
|
||||
(b.builtin ? names : uploaded).push_back(b.name);
|
||||
std::sort(uploaded.begin(), uploaded.end(),
|
||||
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
|
||||
std::sort(uploaded.begin(), uploaded.end(), lessName);
|
||||
names.insert(names.end(), uploaded.begin(), uploaded.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
// A dropdown with one fixed option (the clock, or blank) followed by the bitmaps, for a
|
||||
// setting that holds either the fixed value or a bitmap name
|
||||
struct BitmapChoice
|
||||
// The dynamic pages the same way, followed by the bitmaps
|
||||
static std::vector<String> pageNames()
|
||||
{
|
||||
std::vector<String> names, stored;
|
||||
for (const PageInfo &p : listPages())
|
||||
(p.builtin ? names : stored).push_back(p.name);
|
||||
std::sort(stored.begin(), stored.end(), lessName);
|
||||
names.insert(names.end(), stored.begin(), stored.end());
|
||||
std::vector<String> bitmaps = bitmapNames();
|
||||
names.insert(names.end(), bitmaps.begin(), bitmaps.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
// Changes when bitmaps or pages were added or deleted
|
||||
static uint32_t listRevision()
|
||||
{
|
||||
return bitmapListRevision() + pageListRevision();
|
||||
}
|
||||
|
||||
// A dropdown for a setting that holds a name from the list, or optionally a fixed value
|
||||
// shown as an extra first option (the blank off image)
|
||||
struct NameChoice
|
||||
{
|
||||
HAPageSelect &select;
|
||||
const char *fixed_option; // shown in Home Assistant
|
||||
const char *fixed_option; // shown in Home Assistant; nullptr: no fixed option
|
||||
const char *fixed_value; // stored in the settings
|
||||
const char *fallback; // shown when the setting's name is gone (what the display shows then)
|
||||
std::vector<String> (*names)();
|
||||
const char *(*setting)();
|
||||
const char *(*choose)(const char *value); // returns nullptr or an error
|
||||
|
||||
@@ -106,28 +123,29 @@ struct BitmapChoice
|
||||
char state_for[PAGE_NAME_SIZE]; // setting the state was published for
|
||||
std::vector<String> options;
|
||||
|
||||
// The option for the setting; the fixed one when the bitmap is gone (the display
|
||||
// falls back to the clock or blank then too)
|
||||
const char *current()
|
||||
{
|
||||
const char *value = setting();
|
||||
if (strcmp(value, fixed_value) != 0)
|
||||
for (const String &option : options)
|
||||
if (strcasecmp(option.c_str(), value) == 0)
|
||||
return option.c_str();
|
||||
return fixed_option;
|
||||
if (fixed_option && strcmp(value, fixed_value) == 0)
|
||||
return fixed_option;
|
||||
for (const String &option : options)
|
||||
if (strcasecmp(option.c_str(), value) == 0)
|
||||
return option.c_str();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Rebuilds the options when bitmaps were added or deleted and reports the setting
|
||||
// Rebuilds the options when bitmaps or pages were added or deleted and reports the setting
|
||||
void update()
|
||||
{
|
||||
if (!options_loaded || options_revision != bitmapListRevision())
|
||||
if (!options_loaded || options_revision != listRevision())
|
||||
{
|
||||
const std::vector<String> &names = bitmapNames();
|
||||
options_loaded = true;
|
||||
options_revision = bitmapListRevision();
|
||||
options.assign(1, fixed_option);
|
||||
options.insert(options.end(), names.begin(), names.end());
|
||||
options_revision = listRevision();
|
||||
options.clear();
|
||||
if (fixed_option)
|
||||
options.push_back(fixed_option);
|
||||
std::vector<String> list = names();
|
||||
options.insert(options.end(), list.begin(), list.end());
|
||||
select.setOptions(options);
|
||||
state_known = false;
|
||||
}
|
||||
@@ -142,17 +160,19 @@ struct BitmapChoice
|
||||
|
||||
void command(const char *option)
|
||||
{
|
||||
const char *error = choose(strcmp(option, fixed_option) == 0 ? fixed_value : option);
|
||||
const char *error = choose(fixed_option && strcmp(option, fixed_option) == 0 ? fixed_value : option);
|
||||
if (error)
|
||||
Serial.printf("Home Assistant \"%s\": %s\n", option, error);
|
||||
update(); // report the (new) state back
|
||||
}
|
||||
};
|
||||
|
||||
// Bitmaps can't be called "clock", and the blank off image is stored as ""
|
||||
static BitmapChoice mode = {modeSelect, "Clock", PAGE_CLOCK, []() { return appSettings().page; }, appSelectPage};
|
||||
static BitmapChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, []() { return appSettings().off_page; },
|
||||
appSelectOffPage};
|
||||
// "Clock" is the built-in page (it was a fixed option before, so automations keep working)
|
||||
static NameChoice mode = {modeSelect, nullptr, nullptr, DEFAULT_PAGE, pageNames,
|
||||
[]() { return appSettings().page; }, appSelectPage};
|
||||
// The blank off image is stored as ""
|
||||
static NameChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, "Blank", bitmapNames,
|
||||
[]() { return appSettings().off_page; }, appSelectOffPage};
|
||||
|
||||
static void clearRemovedEntities()
|
||||
{
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
// Home Assistant integration over MQTT (with discovery):
|
||||
// FlipDot Display Power switch: off = draw the off image and switch the display off
|
||||
// FlipDot Display Mode select: the page shown during the day ("Clock" or a bitmap)
|
||||
// FlipDot Display Mode select: the page shown during the day (a dynamic page, e.g.
|
||||
// the built-in "Clock", or a bitmap)
|
||||
// FlipDot Display Off image select: left on the dots when the display switches off
|
||||
// ("Blank" or a bitmap)
|
||||
// FlipDot Display IP sensor: the IP address
|
||||
|
||||
Reference in new issue
Block a user