Add a page schedule with rotation, Home Assistant trigger rules and a Stopwatch tab

- Schedule: daily blocks from a start time, each with up to 6 rotating pages
  or bitmaps (own duration), switching with the effect or instantly. A manual
  pick (Show tab, HA Mode) lasts until the next block; Back to schedule ends it.
- Rules: an HA state (=, is not, above, below) shows a page while true, adds it
  to the rotation while true, or shows it for N seconds when it becomes true.
  Each rule is polled at its own interval, with a 60 s back-off on errors.
- PageSelector (pure logic, tested) decides the shown page; schedule stored in
  NVS; HA sensor Shown page and button Back to schedule.
- Web UI: Schedule and Stopwatch tabs, bitmaps used by pages or rules in their
  own subsection, schedule in export/import. Version 2.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:36:56 +02:00
1 parent 47ef65ee7d
commit 93b9bec69a
25 files changed
+1217 -50

No files matched your search

+41 -12
View File
@@ -14,15 +14,23 @@ struct Entry
String attribute;
String value;
bool known;
bool watched;
bool page_watched; // used by the shown page: read every HA_POLL_INTERVAL
uint32_t rule_interval; // used by trigger rules: read this often (ms), 0 = not
uint32_t next_poll;
bool watched() const { return page_watched || rule_interval; }
uint32_t interval() const
{
uint32_t page = page_watched ? HA_POLL_INTERVAL : UINT32_MAX;
return rule_interval && rule_interval < page ? rule_interval : page;
}
};
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;
static const size_t MAX_ENTRIES = 24;
void haApiSetSettings(const HomeAssistantSettings &settings)
{
@@ -52,19 +60,19 @@ static Entry &add(const char *entity, const char *attribute)
return *e;
if (entries.size() >= MAX_ENTRIES)
for (auto it = entries.begin(); it != entries.end(); ++it)
if (!it->watched)
if (!it->watched())
{
entries.erase(it);
break;
}
entries.push_back({entity, attribute, "", false, false, millis()});
entries.push_back({entity, attribute, "", false, false, 0, millis()});
return entries.back();
}
void haApiWatch(const DynamicPage *page)
{
for (Entry &e : entries)
e.watched = false;
e.page_watched = false;
if (!page)
return;
for (int i = 0; i < page->element_count; i++)
@@ -74,9 +82,27 @@ void haApiWatch(const DynamicPage *page)
if (!uses_state || !validEntityId(element.entity))
continue;
Entry &e = add(element.entity, element.attribute);
if (!e.watched)
if (!e.watched())
e.next_poll = millis();
e.watched = true;
e.page_watched = true;
}
}
void haApiWatchRules(const PageSchedule *schedule)
{
for (Entry &e : entries)
e.rule_interval = 0;
if (!schedule)
return;
for (int i = 0; i < schedule->rule_count; i++)
{
const TriggerRule &rule = schedule->rules[i];
Entry &e = add(rule.entity, rule.attribute);
if (!e.watched())
e.next_poll = millis();
uint32_t ms = rule.interval * 1000UL;
if (!e.rule_interval || ms < e.rule_interval)
e.rule_interval = ms;
}
}
@@ -139,20 +165,23 @@ bool haApiLoop(bool wifi_connected)
uint32_t now = millis();
for (Entry &e : entries)
{
if (!e.watched || (int32_t)(now - e.next_poll) < 0)
if (!e.watched() || (int32_t)(now - e.next_poll) < 0)
continue;
e.next_poll = now + HA_POLL_INTERVAL;
e.next_poll = now + e.interval();
String value;
const char *error = fetch(e.entity, e.attribute, value);
if (error)
{
// Shown as "--" until it can be read again
// 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);
bool changed = e.known;
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;
}
bool changed = !e.known || e.value != value;
bool changed = (!e.known || e.value != value) && e.page_watched;
e.value = value;
e.known = true;
return changed; // one entity per loop()
+9 -3
View File
@@ -3,12 +3,14 @@
#include <Arduino.h>
#include "logic/page.h"
#include "logic/page_schedule.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.
// 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.
// URL and token; empty URL: http://<MQTT broker>:8123
void haApiSetSettings(const HomeAssistantSettings &settings);
@@ -16,7 +18,11 @@ 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).
// 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.
bool haApiLoop(bool wifi_connected);
// The last value read for an entity (attribute empty: the state); nullptr when unknown
+12
View File
@@ -30,6 +30,8 @@ static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery");
static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch");
static HAButton stopwatchReset("FlipDotDisplayStopwatchReset");
static HASensor shownSensor("FlipDotDisplayShownPage");
static HAButton backButton("FlipDotDisplayBackToSchedule");
// Removed entities: their retained discovery config is cleared, so Home Assistant
// deletes them instead of showing them as unavailable
@@ -61,6 +63,7 @@ struct TextSensor
static TextSensor firmware = {firmwareSensor, "", false};
static TextSensor ip = {ipSensor, "", false};
static TextSensor shownPage = {shownSensor, "", false};
static DisplayController *displayController = nullptr;
static bool enabled = false;
@@ -233,6 +236,12 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
shownSensor.setName("FlipDot Display Shown page");
shownSensor.setIcon("mdi:monitor-eye");
backButton.setName("FlipDot Display Back to schedule");
backButton.setIcon("mdi:calendar-clock");
backButton.onCommand([](HAButton *) { appBackToSchedule(); });
firmwareSensor.setName("FlipDot Display Firmware");
firmwareSensor.setIcon("mdi:chip");
@@ -241,6 +250,7 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
mqtt.onConnected([]() {
firmware.sent = false;
shownPage.sent = false;
ip.sent = false;
clearRemovedEntities();
});
@@ -271,6 +281,8 @@ void homeAssistantLoop()
ip.set(WiFi.localIP().toString().c_str());
}
ip.loop();
shownPage.set(appShownPage());
shownPage.loop();
firmware.loop();
mode.update();
offImage.update();
+2
View File
@@ -14,6 +14,8 @@
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
// FlipDot Display Stopwatch switch: the stopwatch of the dynamic pages runs
// FlipDot Display Stopwatch reset button: back to 0
// FlipDot Display Shown page sensor: the page or bitmap on the display now
// FlipDot Display Back to schedule button: ends a manual pick (the Mode dropdown)
//
// The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER
// and MQTT_PASSWORD from secrets.h. Changes need a restart.