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
+4
-1
@@ -91,7 +91,10 @@ static void apply()
|
||||
controller.setEffectTime(effectTime(settings.drive));
|
||||
status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots);
|
||||
frames.setDrive(settings.flip_time, settings.drive);
|
||||
haApiSetSettings(settings.home_assistant);
|
||||
char user[PLEX_USER_SIZE];
|
||||
plexUserSlug(settings.plex_user, user, sizeof(user));
|
||||
haApiSetSettings(settings.home_assistant, user);
|
||||
haApiWatchRules(&page_schedule); // {plex_user} may have changed
|
||||
applyOffPage();
|
||||
applyPage();
|
||||
}
|
||||
|
||||
+9
-3
@@ -46,11 +46,17 @@ const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000;
|
||||
// Power off this long after the display is no longer needed (night, standby)
|
||||
const uint32_t POWER_OFF_DELAY = 30000;
|
||||
// Home Assistant values on dynamic pages (net/ha_api): read this often, and the HTTP
|
||||
// timeout (a read blocks loop() for at most about twice this)
|
||||
// timeout (connect, then the answer; the requests run in their own task, loop() doesn't wait)
|
||||
const uint32_t HA_POLL_INTERVAL = 60000;
|
||||
const uint32_t HA_HTTP_TIMEOUT = 1500;
|
||||
const uint32_t HA_HTTP_TIMEOUT = 5000;
|
||||
// After a failed read (Home Assistant down) an entity is read again after this at the soonest
|
||||
const uint32_t HA_ERROR_INTERVAL = 60000;
|
||||
// The task that does the requests (net/ha_api): core 0 runs WiFi and the network stack,
|
||||
// loop() runs on core 1. Stack in bytes.
|
||||
const int HA_TASK_CORE = 0;
|
||||
const uint32_t HA_TASK_STACK = 8192;
|
||||
// A value of the web UI's test button is kept this long for the page to pick it up (ms)
|
||||
const uint32_t HA_PREVIEW_KEEP = 10000;
|
||||
// Moving dynamic pages (scrolling, seconds): the next frame waits until the Nano received
|
||||
// this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin
|
||||
const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000;
|
||||
@@ -112,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
|
||||
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
||||
|
||||
#define FIRMWARE_VERSION "2.8.0"
|
||||
#define FIRMWARE_VERSION "2.9.0"
|
||||
|
||||
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "display/bitmap_store.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
#include <map>
|
||||
#include <strings.h>
|
||||
|
||||
#include "display/bitmaps.h"
|
||||
@@ -22,6 +23,18 @@ static const BuiltinBitmap BUILTIN[] = {
|
||||
static bool mounted = false;
|
||||
static uint32_t revision = 0;
|
||||
|
||||
// Bitmaps read before, also the names that don't exist (images try a name per state, then
|
||||
// "default"). Reading one from LittleFS takes about 100 ms, and pages are drawn again for
|
||||
// every frame (scrolling, seconds) and for every preview in the web UI. Cleared when a
|
||||
// bitmap is saved or deleted, and when it holds CACHE_MAX names.
|
||||
struct CachedBitmap
|
||||
{
|
||||
bool exists;
|
||||
uint8_t data[BITMAP_BYTES];
|
||||
};
|
||||
static std::map<String, CachedBitmap> cache;
|
||||
static const size_t CACHE_MAX = 64;
|
||||
|
||||
static const BuiltinBitmap *builtin(const char *name)
|
||||
{
|
||||
for (const BuiltinBitmap &b : BUILTIN)
|
||||
@@ -99,7 +112,8 @@ bool bitmapExists(const char *name)
|
||||
{
|
||||
if (builtin(name))
|
||||
return true;
|
||||
return mounted && validBitmapName(name) && LittleFS.exists(path(name));
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
return loadBitmap(name, bitmap); // through the cache
|
||||
}
|
||||
|
||||
bool loadBitmap(const char *name, uint8_t *bitmap)
|
||||
@@ -110,14 +124,33 @@ bool loadBitmap(const char *name, uint8_t *bitmap)
|
||||
memcpy_P(bitmap, b->data, BITMAP_BYTES);
|
||||
return true;
|
||||
}
|
||||
if (!mounted || !validBitmapName(name))
|
||||
return false;
|
||||
auto cached = cache.find(name);
|
||||
if (cached != cache.end())
|
||||
{
|
||||
if (cached->second.exists)
|
||||
memcpy(bitmap, cached->second.data, BITMAP_BYTES);
|
||||
return cached->second.exists;
|
||||
}
|
||||
|
||||
// Checked first: open() logs an error for a missing file (images try names by state)
|
||||
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name)))
|
||||
return false;
|
||||
File file = LittleFS.open(path(name), "r");
|
||||
if (!file)
|
||||
return false;
|
||||
bool ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
|
||||
file.close();
|
||||
bool ok = false;
|
||||
if (LittleFS.exists(path(name)))
|
||||
{
|
||||
File file = LittleFS.open(path(name), "r");
|
||||
if (file)
|
||||
{
|
||||
ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
if (cache.size() >= CACHE_MAX)
|
||||
cache.clear();
|
||||
CachedBitmap &entry = cache[name];
|
||||
entry.exists = ok;
|
||||
if (ok)
|
||||
memcpy(entry.data, bitmap, BITMAP_BYTES);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -138,6 +171,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
|
||||
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
|
||||
file.close();
|
||||
revision++;
|
||||
cache.clear();
|
||||
return ok ? nullptr : "Storage full";
|
||||
}
|
||||
|
||||
@@ -150,5 +184,6 @@ const char *deleteBitmap(const char *name)
|
||||
if (!LittleFS.remove(path(name)))
|
||||
return "Could not delete the file";
|
||||
revision++;
|
||||
cache.clear();
|
||||
return nullptr;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
static const char *DIR = "/pages";
|
||||
static const char *EXTENSION = ".pg";
|
||||
// Increase when DynamicPage changes; older files are ignored unless converted below
|
||||
static const uint16_t VERSION = 3;
|
||||
static const uint16_t VERSION = 4;
|
||||
|
||||
struct StoredPage
|
||||
{
|
||||
@@ -149,7 +149,66 @@ static void convert(const Page &old, DynamicPage &page)
|
||||
}
|
||||
} // namespace v2
|
||||
|
||||
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(StoredPage),
|
||||
// Version 3 (before the progress bar): the elements lacked the fields from `bar_width` on.
|
||||
// The rest has the same layout, so it is copied as a block.
|
||||
namespace v3
|
||||
{
|
||||
struct Element
|
||||
{
|
||||
ElementType type;
|
||||
int16_t x, y;
|
||||
Align align;
|
||||
Overlay overlay;
|
||||
char font[PAGE_NAME_SIZE];
|
||||
uint8_t format;
|
||||
Language language;
|
||||
int8_t decimals;
|
||||
char text[ELEMENT_TEXT_SIZE];
|
||||
char suffix[ELEMENT_SUFFIX_SIZE];
|
||||
char entity[ELEMENT_ENTITY_SIZE];
|
||||
char attribute[ELEMENT_ATTRIBUTE_SIZE];
|
||||
float factor;
|
||||
uint8_t scroll_width;
|
||||
uint8_t scroll_speed;
|
||||
uint32_t target;
|
||||
HaShow show;
|
||||
uint8_t input_unit;
|
||||
bool auto_unit;
|
||||
bool absolute;
|
||||
char map[ELEMENT_MAP_SIZE];
|
||||
};
|
||||
|
||||
struct Page
|
||||
{
|
||||
uint8_t slot_count;
|
||||
PageSlot slots[PAGE_SLOTS];
|
||||
uint8_t element_count;
|
||||
Element elements[PAGE_ELEMENTS];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
{
|
||||
uint16_t version;
|
||||
uint16_t size;
|
||||
Page page;
|
||||
};
|
||||
|
||||
static const size_t COMMON = offsetof(Element, map) + sizeof(Element::map);
|
||||
static_assert(offsetof(PageElement, map) == offsetof(Element, map), "same layout up to map");
|
||||
|
||||
static void convert(const Page &old, DynamicPage &page)
|
||||
{
|
||||
page = DynamicPage();
|
||||
page.slot_count = old.slot_count;
|
||||
memcpy(page.slots, old.slots, sizeof(page.slots));
|
||||
page.element_count = old.element_count;
|
||||
for (int i = 0; i < PAGE_ELEMENTS; i++)
|
||||
memcpy((void *)&page.elements[i], &old.elements[i], COMMON); // the bar fields keep their defaults
|
||||
}
|
||||
} // namespace v3
|
||||
|
||||
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) &&
|
||||
sizeof(v3::Stored) != sizeof(StoredPage),
|
||||
"the versions are told apart by their size");
|
||||
|
||||
static uint32_t revision = 0;
|
||||
@@ -210,6 +269,18 @@ bool loadPage(const char *name, DynamicPage &page)
|
||||
page = stored->page;
|
||||
delete stored;
|
||||
}
|
||||
else if (size == sizeof(v3::Stored))
|
||||
{
|
||||
v3::Stored *stored = new v3::Stored;
|
||||
ok = file.read((uint8_t *)stored, sizeof(v3::Stored)) == sizeof(v3::Stored) && stored->version == 3 &&
|
||||
stored->size == sizeof(v3::Page);
|
||||
if (ok)
|
||||
{
|
||||
v3::convert(stored->page, page);
|
||||
ok = validatePage(page) == nullptr;
|
||||
}
|
||||
delete stored;
|
||||
}
|
||||
else if (size == sizeof(v2::Stored))
|
||||
{
|
||||
v2::Stored *stored = new v2::Stored;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -62,6 +62,7 @@ void setup()
|
||||
status.show("Getting", "the time...", true);
|
||||
|
||||
configTzTime(TIMEZONE, NTP_SERVER);
|
||||
haApiBegin(); // Home Assistant values are read in a task of their own
|
||||
homeAssistantBegin(controller, appSettings().home_assistant);
|
||||
webUiBegin();
|
||||
|
||||
@@ -80,7 +81,7 @@ void loop()
|
||||
homeAssistantLoop();
|
||||
webUiLoop();
|
||||
appLoop();
|
||||
// Home Assistant values of the shown page (can block about 3 s once a minute)
|
||||
// Home Assistant values of the shown page (read by a task, never blocks)
|
||||
if (haApiLoop(wifiConnected()))
|
||||
controller.pageChanged();
|
||||
|
||||
|
||||
+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);
|
||||
@@ -9,7 +9,7 @@ static const char *KEY = "settings";
|
||||
// Increase when the Settings struct changes; old records are then ignored (defaults),
|
||||
// unless the code below says how to read them. When fields are only added at the end,
|
||||
// an old record is the start of the new layout: add an OldVersion for it like v3 does.
|
||||
static const uint16_t VERSION = 5;
|
||||
static const uint16_t VERSION = 6;
|
||||
|
||||
struct OldVersion
|
||||
{
|
||||
@@ -78,8 +78,12 @@ static void convert(const Settings &old, ::Settings &s)
|
||||
|
||||
static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer");
|
||||
|
||||
// Version 4: the current layout without refresh (added at the end, so it is a prefix)
|
||||
static const OldVersion V4 = {4, offsetof(Settings, refresh)};
|
||||
// Versions 4 and 5: the current layout without refresh and plex_user (added at the end, so
|
||||
// they are a prefix of it)
|
||||
static const OldVersion PREFIXES[] = {
|
||||
{4, offsetof(Settings, refresh)},
|
||||
{5, offsetof(Settings, plex_user)},
|
||||
};
|
||||
|
||||
struct StoredSettings
|
||||
{
|
||||
@@ -111,9 +115,13 @@ void loadSettings(Settings &settings)
|
||||
Settings loaded;
|
||||
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings))
|
||||
loaded = stored.settings;
|
||||
else if (stored.version == V4.version)
|
||||
else if (stored.version >= 4 && stored.version < VERSION)
|
||||
{
|
||||
if (!readOld(stored, read, V4.fields, loaded))
|
||||
bool ok = false;
|
||||
for (const OldVersion &o : PREFIXES)
|
||||
if (o.version == stored.version)
|
||||
ok = readOld(stored, read, o.fields, loaded);
|
||||
if (!ok)
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
#include "logic/settings.h"
|
||||
|
||||
static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image"};
|
||||
static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image", "bar"};
|
||||
static const char *const ALIGNS[] = {"left", "center", "right"};
|
||||
static const char *const OVERLAYS[] = {"top", "box", "invert"};
|
||||
static const char *const LANGUAGES[] = {"nl", "en"};
|
||||
@@ -69,6 +69,10 @@ void pageToJson(const DynamicPage &page, JsonObject out)
|
||||
o["auto_unit"] = e.auto_unit;
|
||||
o["absolute"] = e.absolute;
|
||||
o["map"] = withDegrees(e.map);
|
||||
o["bar_width"] = e.bar_width;
|
||||
o["bar_height"] = e.bar_height;
|
||||
o["bar_max"] = e.bar_max;
|
||||
o["bar_frame"] = e.bar_frame;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +132,10 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
|
||||
e.input_unit = unit;
|
||||
e.auto_unit = o["auto_unit"] | false;
|
||||
e.absolute = o["absolute"] | false;
|
||||
e.bar_width = constrain(o["bar_width"] | 40, 0, 255);
|
||||
e.bar_height = constrain(o["bar_height"] | 3, 0, 255);
|
||||
e.bar_max = o["bar_max"] | 100.0f;
|
||||
e.bar_frame = o["bar_frame"] | true;
|
||||
if (!copyText(o["map"], e.map, sizeof(e.map)))
|
||||
return "The list of texts is too long (63 characters)";
|
||||
const char *target = o["target"] | "";
|
||||
|
||||
+23
-11
@@ -74,8 +74,8 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
|
||||
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
|
||||
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button></div>
|
||||
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type ° for degrees. A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button></div>
|
||||
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type ° for degrees. Entity ids can contain {plex_user} (Home Assistant tab). A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
|
||||
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
|
||||
<h3>Add a font</h3>
|
||||
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
|
||||
@@ -167,7 +167,8 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form">
|
||||
<label>URL</label><input id="ha_url" placeholder="empty: http://<broker>:8123">
|
||||
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
|
||||
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute.</p></div>
|
||||
<label>Plex user</label><input id="ha_plex" placeholder="e.g. mangavalk" autocomplete="off">
|
||||
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute. Plex user: entity ids in pages and rules can contain {plex_user}, which is replaced by this name the way Home Assistant writes it (lower case, _ for spaces), e.g. sensor.{plex_user}_state for Tautulli.</p></div>
|
||||
<button class="b" onclick="saveHa()">Save</button></section>
|
||||
|
||||
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
|
||||
@@ -291,7 +292,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
|
||||
rd.append(mk('div',{className:'el'},
|
||||
mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}),
|
||||
sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}),
|
||||
btn('Test',async()=>{const v=await api('/api/ha/value?entity='+encodeURIComponent(ru.entity)+'&attribute='+encodeURIComponent(ru.attribute));state.textContent='now: '+v.value})),
|
||||
btn('Test',async()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
|
||||
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
|
||||
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')),
|
||||
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
|
||||
@@ -306,14 +307,15 @@ async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedul
|
||||
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
|
||||
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
|
||||
// page editor: the ESP32 draws the preview and returns the element boxes
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image'};
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar'};
|
||||
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
|
||||
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
|
||||
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
|
||||
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
|
||||
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
|
||||
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
|
||||
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:''}}
|
||||
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:'',
|
||||
bar_width:40,bar_height:3,bar_max:100,bar_frame:true}}
|
||||
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
|
||||
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
|
||||
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
|
||||
@@ -364,6 +366,12 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
|
||||
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
|
||||
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
|
||||
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
|
||||
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
||||
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
|
||||
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})),
|
||||
chk(e,'bar_frame','frame (from 3 x 3 dots)'));
|
||||
rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))}
|
||||
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
|
||||
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
|
||||
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
|
||||
@@ -371,6 +379,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl);
|
||||
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
|
||||
inp(e,'scroll_speed','number',{style:'width:4em',min:1,max:30}),'dots per second')}
|
||||
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
|
||||
else row(...pos,ovl);
|
||||
box.append(...rows);d.append(box)})}
|
||||
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
|
||||
@@ -397,7 +406,10 @@ pc.onpointermove=e=>{if(!peDrag)return;const[x,y]=pcPos(e),el=pe.page.elements[p
|
||||
pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()};
|
||||
document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];
|
||||
if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()});
|
||||
async function haTest(e){const r=await api('/api/ha/value?entity='+encodeURIComponent(e.entity)+'&attribute='+encodeURIComponent(e.attribute));msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+r.value);pePreview()}
|
||||
// The ESP32 reads the value in the background: ask again until it is there
|
||||
async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api('/api/ha/value?entity='+encodeURIComponent(entity)+'&attribute='+encodeURIComponent(attribute));
|
||||
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
|
||||
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()}
|
||||
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
|
||||
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
|
||||
async function peLive(){if(peData)await live(peData)}
|
||||
@@ -431,9 +443,9 @@ async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg
|
||||
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
|
||||
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
|
||||
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
|
||||
['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,status_led:s.status_led})],
|
||||
['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,refresh:s.refresh,status_led:s.status_led})],
|
||||
['mqtt','Home Assistant MQTT (restarts the ESP32)',s=>{const h=s.home_assistant||{},o={enabled:h.enabled,host:h.host,port:h.port,user:h.user};if(h.password!==undefined)o.password=h.password;return{home_assistant:o}}],
|
||||
['haapi','Home Assistant values (URL, token)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o}}]];
|
||||
['haapi','Home Assistant values (URL, token, Plex user)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o,plex_user:s.plex_user}}]];
|
||||
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
|
||||
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
|
||||
document.body.append(a);a.click();a.remove()}
|
||||
@@ -543,14 +555,14 @@ function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day')
|
||||
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
|
||||
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image;
|
||||
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
|
||||
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;
|
||||
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;$('ha_plex').value=s.plex_user||'';
|
||||
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'}
|
||||
function collect(){const s=JSON.parse(JSON.stringify(settings));
|
||||
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
|
||||
s.clock={no_wifi_screen:$('c_nw').checked};
|
||||
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
|
||||
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value};
|
||||
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});
|
||||
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});s.plex_user=$('ha_plex').value.trim();
|
||||
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s}
|
||||
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
|
||||
async function saveHa(){await saveSettings()}
|
||||
|
||||
+37
-14
@@ -224,11 +224,26 @@ static void handleScreen()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Streamed: with many bitmaps the JSON is too big to build in memory at once
|
||||
// Long lists are sent in pieces of about this many bytes. Small pieces (like a lone comma)
|
||||
// each wait for the browser's delayed ACK (Nagle), up to 200 ms each, while loop() waits.
|
||||
static const size_t SEND_PIECE = 2048;
|
||||
|
||||
static void sendPiece(String &out, bool last = false)
|
||||
{
|
||||
if (out.length() >= SEND_PIECE || (last && out.length()))
|
||||
{
|
||||
server.sendContent(out);
|
||||
out = "";
|
||||
}
|
||||
if (last)
|
||||
server.sendContent(""); // end of the chunked answer
|
||||
}
|
||||
|
||||
static void handleBitmaps()
|
||||
{
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "application/json", "");
|
||||
server.sendContent(bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":[");
|
||||
String out = bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":[";
|
||||
|
||||
bool first = true;
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
@@ -239,13 +254,13 @@ static void handleBitmaps()
|
||||
continue;
|
||||
bitmapToHex(bitmap, hex);
|
||||
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
|
||||
String item = String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" +
|
||||
(info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}";
|
||||
server.sendContent(item);
|
||||
out += String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" +
|
||||
(info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}";
|
||||
first = false;
|
||||
sendPiece(out);
|
||||
}
|
||||
server.sendContent("]}");
|
||||
server.sendContent("");
|
||||
out += "]}";
|
||||
sendPiece(out, true);
|
||||
}
|
||||
|
||||
static void handleBitmapGet()
|
||||
@@ -297,7 +312,7 @@ static void handlePages()
|
||||
{
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "application/json", "");
|
||||
server.sendContent("{\"pages\":[");
|
||||
String out = "{\"pages\":[";
|
||||
|
||||
bool first = true;
|
||||
DynamicPage *page = new DynamicPage;
|
||||
@@ -316,15 +331,16 @@ static void handlePages()
|
||||
doc["data"] = hex;
|
||||
pageToJson(*page, doc["page"].to<JsonObject>());
|
||||
String item;
|
||||
serializeJson(doc, item); // replaces the string's content, so the comma is sent apart
|
||||
serializeJson(doc, item); // replaces the string's content, so it is added to out after
|
||||
if (!first)
|
||||
server.sendContent(",");
|
||||
server.sendContent(item);
|
||||
out += ",";
|
||||
out += item;
|
||||
first = false;
|
||||
sendPiece(out);
|
||||
}
|
||||
delete page;
|
||||
server.sendContent("]}");
|
||||
server.sendContent("");
|
||||
out += "]}";
|
||||
sendPiece(out, true);
|
||||
}
|
||||
|
||||
static void handlePageSave()
|
||||
@@ -457,15 +473,20 @@ static void handleFontDelete()
|
||||
// Reads a value now (test button in the page editor); it is then also used in previews
|
||||
static void handleHaValue()
|
||||
{
|
||||
// Read in the background: {"pending":true} until the value is there (ask again)
|
||||
String value;
|
||||
const char *error = haApiFetchNow(server.arg("entity").c_str(), server.arg("attribute").c_str(), value);
|
||||
bool pending;
|
||||
const char *error = haApiPreview(server.arg("entity").c_str(), server.arg("attribute").c_str(), value, pending);
|
||||
if (error)
|
||||
{
|
||||
sendError(400, error);
|
||||
return;
|
||||
}
|
||||
JsonDocument doc;
|
||||
doc["value"] = value;
|
||||
if (pending)
|
||||
doc["pending"] = true;
|
||||
else
|
||||
doc["value"] = value;
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
@@ -612,6 +633,7 @@ static void settingsToJson(const Settings &s, JsonObject o)
|
||||
ha["user"] = s.home_assistant.user;
|
||||
ha["password_set"] = s.home_assistant.password[0] != '\0';
|
||||
ha["api_url"] = s.home_assistant.api_url;
|
||||
o["plex_user"] = s.plex_user;
|
||||
ha["token_set"] = s.home_assistant.token[0] != '\0';
|
||||
}
|
||||
|
||||
@@ -679,6 +701,7 @@ static void handleSettingsPost()
|
||||
copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user));
|
||||
copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password));
|
||||
copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url));
|
||||
copyText(body["plex_user"], s.plex_user, sizeof(s.plex_user));
|
||||
copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token));
|
||||
|
||||
bool restart = false;
|
||||
|
||||
Reference in new issue
Block a user