Public Access
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:
1 parent
47ef65ee7d
commit
93b9bec69a
25 files changed
+1217
-50
No files matched your search
@@ -0,0 +1,112 @@
|
||||
#include "logic/page_schedule.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static bool terminated(const char *text, size_t size)
|
||||
{
|
||||
return memchr(text, '\0', size) != nullptr;
|
||||
}
|
||||
|
||||
static bool validAttribute(const char *attribute)
|
||||
{
|
||||
for (const char *c = attribute; *c; c++)
|
||||
if (!isalnum((unsigned char)*c) && *c != '_')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *validateSchedule(const PageSchedule &s)
|
||||
{
|
||||
if (s.block_count > SCHEDULE_BLOCKS)
|
||||
return "Too many schedule blocks (8 at most)";
|
||||
if (s.rule_count > TRIGGER_RULES)
|
||||
return "Too many rules (8 at most)";
|
||||
|
||||
for (int i = 0; i < s.block_count; i++)
|
||||
{
|
||||
const ScheduleBlock &b = s.blocks[i];
|
||||
if (b.start >= 24 * 60)
|
||||
return "Blocks must start before 24:00";
|
||||
if (i > 0 && b.start <= s.blocks[i - 1].start)
|
||||
return "Blocks must be in order, each at a different time";
|
||||
if (b.item_count == 0 || b.item_count > BLOCK_ITEMS)
|
||||
return "Each block needs 1 to 6 pages";
|
||||
for (int j = 0; j < b.item_count; j++)
|
||||
{
|
||||
const ScheduleItem &item = b.items[j];
|
||||
if (!terminated(item.page, sizeof(item.page)) || !validBitmapName(item.page))
|
||||
return "Choose a page for each item of a block";
|
||||
if (item.seconds < ITEM_SECONDS_MIN || item.seconds > ITEM_SECONDS_MAX)
|
||||
return "Pages are shown 5 to 3600 seconds";
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < s.rule_count; i++)
|
||||
{
|
||||
const TriggerRule &r = s.rules[i];
|
||||
if (!terminated(r.entity, sizeof(r.entity)) || !validEntityId(r.entity))
|
||||
return "Rules need an entity id like sensor.printer_state";
|
||||
if (!terminated(r.attribute, sizeof(r.attribute)) || !validAttribute(r.attribute))
|
||||
return "Invalid attribute";
|
||||
if (r.compare >= Compare::Count || r.mode >= TriggerMode::Count)
|
||||
return "Unknown rule setting";
|
||||
if (!terminated(r.value, sizeof(r.value)))
|
||||
return "Rule value too long (32 characters)";
|
||||
if (!terminated(r.page, sizeof(r.page)) || !validBitmapName(r.page))
|
||||
return "Choose a page for each rule";
|
||||
if (r.mode != TriggerMode::WhileTrue && (r.seconds < 1 || r.seconds > ITEM_SECONDS_MAX))
|
||||
return "Rule times must be 1 to 3600 seconds";
|
||||
if (r.interval < RULE_INTERVAL_MIN || r.interval > RULE_INTERVAL_MAX)
|
||||
return "Rules are read every 2 to 600 seconds";
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static bool sameText(const char *a, const char *b)
|
||||
{
|
||||
for (; *a && *b; a++, b++)
|
||||
if (tolower((unsigned char)*a) != tolower((unsigned char)*b))
|
||||
return false;
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
static bool number(const char *text, double &value)
|
||||
{
|
||||
char *end;
|
||||
value = strtod(text, &end);
|
||||
return end != text && *end == '\0';
|
||||
}
|
||||
|
||||
bool ruleMatches(const TriggerRule &rule, const char *value)
|
||||
{
|
||||
if (value == nullptr)
|
||||
return false;
|
||||
double a, b;
|
||||
switch (rule.compare)
|
||||
{
|
||||
case Compare::Equal:
|
||||
return sameText(value, rule.value);
|
||||
case Compare::NotEqual:
|
||||
return !sameText(value, rule.value);
|
||||
case Compare::Above:
|
||||
return number(value, a) && number(rule.value, b) && a > b;
|
||||
case Compare::Below:
|
||||
return number(value, a) && number(rule.value, b) && a < b;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool scheduleUses(const PageSchedule &s, const char *name)
|
||||
{
|
||||
for (int i = 0; i < s.block_count; i++)
|
||||
for (int j = 0; j < s.blocks[i].item_count; j++)
|
||||
if (sameName(s.blocks[i].items[j].page, name))
|
||||
return true;
|
||||
for (int i = 0; i < s.rule_count; i++)
|
||||
if (sameName(s.rules[i].page, name))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "logic/page.h"
|
||||
|
||||
// What is shown when: schedule blocks (pages or bitmaps that rotate, from a time of day
|
||||
// until the next block) and trigger rules (Home Assistant states that bring a page to the
|
||||
// front). Edited in the web UI, stored in NVS (system/schedule_store). Which page is shown
|
||||
// right now is decided by logic/page_selector. Pure logic, tested in test/test_esp32.
|
||||
//
|
||||
// The display schedule (logic/schedule.h) still decides when the display is on.
|
||||
|
||||
const int SCHEDULE_BLOCKS = 8;
|
||||
const int BLOCK_ITEMS = 6;
|
||||
const int TRIGGER_RULES = 8;
|
||||
const int RULE_VALUE_SIZE = 33;
|
||||
const uint16_t ITEM_SECONDS_MIN = 5;
|
||||
const uint16_t ITEM_SECONDS_MAX = 3600;
|
||||
const uint16_t RULE_INTERVAL_MIN = 2;
|
||||
const uint16_t RULE_INTERVAL_MAX = 600;
|
||||
|
||||
// A page or bitmap, shown for `seconds` when its block rotates
|
||||
struct ScheduleItem
|
||||
{
|
||||
char page[PAGE_NAME_SIZE] = "";
|
||||
uint16_t seconds = 30;
|
||||
};
|
||||
|
||||
// From `start` (minutes since midnight) until the next block
|
||||
struct ScheduleBlock
|
||||
{
|
||||
uint16_t start = 0;
|
||||
bool effect = true; // switch to the next page with the refresh effect (false: instantly)
|
||||
uint8_t item_count = 0;
|
||||
ScheduleItem items[BLOCK_ITEMS];
|
||||
};
|
||||
|
||||
enum class Compare : uint8_t
|
||||
{
|
||||
Equal, // text, without case
|
||||
NotEqual,
|
||||
Above, // numbers
|
||||
Below,
|
||||
Count,
|
||||
};
|
||||
|
||||
enum class TriggerMode : uint8_t
|
||||
{
|
||||
WhileTrue, // the page takes over while the condition is true
|
||||
AddToRotation, // the page joins the rotation (for `seconds`) while it is true
|
||||
ForSeconds, // the page takes over for `seconds` after the condition becomes true
|
||||
Count,
|
||||
};
|
||||
|
||||
struct TriggerRule
|
||||
{
|
||||
char entity[ELEMENT_ENTITY_SIZE] = "";
|
||||
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // empty: the state
|
||||
Compare compare = Compare::Equal;
|
||||
char value[RULE_VALUE_SIZE] = "";
|
||||
char page[PAGE_NAME_SIZE] = "";
|
||||
TriggerMode mode = TriggerMode::WhileTrue;
|
||||
uint16_t seconds = 30; // AddToRotation: time in the rotation; ForSeconds: how long
|
||||
uint16_t interval = 10; // read the entity this often (s)
|
||||
};
|
||||
|
||||
struct PageSchedule
|
||||
{
|
||||
bool enabled = true; // false: the blocks are kept but not used (the rules still work)
|
||||
uint8_t block_count = 0;
|
||||
ScheduleBlock blocks[SCHEDULE_BLOCKS];
|
||||
uint8_t rule_count = 0;
|
||||
TriggerRule rules[TRIGGER_RULES];
|
||||
};
|
||||
|
||||
// Returns nullptr when it is fine, otherwise a message for the user. Does not check that
|
||||
// the pages exist.
|
||||
const char *validateSchedule(const PageSchedule &schedule);
|
||||
|
||||
// Whether a rule's condition is true for this value (nullptr: not known, never true)
|
||||
bool ruleMatches(const TriggerRule &rule, const char *value);
|
||||
|
||||
// Whether the schedule or a rule shows this page or bitmap (compared without case)
|
||||
bool scheduleUses(const PageSchedule &schedule, const char *name);
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "logic/page_selector.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static void copyName(char *out, const char *name)
|
||||
{
|
||||
strncpy(out, name ? name : "", PAGE_NAME_SIZE - 1);
|
||||
out[PAGE_NAME_SIZE - 1] = '\0';
|
||||
}
|
||||
|
||||
void PageSelector::setSchedule(const PageSchedule *schedule)
|
||||
{
|
||||
_schedule = schedule;
|
||||
_picked = false;
|
||||
_block = -1;
|
||||
_last_minute = -1;
|
||||
memset(_rule_active, 0, sizeof(_rule_active));
|
||||
memset(_rule_timer, 0, sizeof(_rule_timer));
|
||||
}
|
||||
|
||||
void PageSelector::setChosen(const char *page)
|
||||
{
|
||||
copyName(_chosen, page);
|
||||
}
|
||||
|
||||
void PageSelector::pick(const char *page)
|
||||
{
|
||||
setChosen(page);
|
||||
_picked = scheduleActive();
|
||||
_item = 0;
|
||||
}
|
||||
|
||||
void PageSelector::backToSchedule()
|
||||
{
|
||||
_picked = false;
|
||||
}
|
||||
|
||||
bool PageSelector::scheduleActive() const
|
||||
{
|
||||
return _schedule && _schedule->enabled && _schedule->block_count > 0;
|
||||
}
|
||||
|
||||
int PageSelector::_blockAt(int minute) const
|
||||
{
|
||||
int block = _schedule->block_count - 1; // before the first start: the last block of the day before
|
||||
for (int i = 0; i < _schedule->block_count; i++)
|
||||
if (_schedule->blocks[i].start <= minute)
|
||||
block = i;
|
||||
return block;
|
||||
}
|
||||
|
||||
int PageSelector::pickedUntil() const
|
||||
{
|
||||
if (!_picked || !scheduleActive() || _block < 0)
|
||||
return -1;
|
||||
return _schedule->blocks[(_block + 1) % _schedule->block_count].start;
|
||||
}
|
||||
|
||||
void PageSelector::setRuleState(int rule, bool active, uint32_t now)
|
||||
{
|
||||
if (rule < 0 || rule >= TRIGGER_RULES)
|
||||
return;
|
||||
if (active && !_rule_active[rule])
|
||||
{
|
||||
// Becomes true: a ForSeconds rule starts (again)
|
||||
_rule_timer[rule] = true;
|
||||
_rule_since[rule] = now;
|
||||
}
|
||||
_rule_active[rule] = active;
|
||||
}
|
||||
|
||||
bool PageSelector::ruleActive(int rule) const
|
||||
{
|
||||
return rule >= 0 && rule < TRIGGER_RULES && _rule_active[rule];
|
||||
}
|
||||
|
||||
PageChoice PageSelector::update(int minute, uint32_t now)
|
||||
{
|
||||
PageChoice choice;
|
||||
bool schedule = scheduleActive();
|
||||
bool effect = true; // switching with the refresh effect
|
||||
|
||||
// A block starts: it ends a manual pick and starts its rotation from the first page.
|
||||
// With one block that is its start time each day.
|
||||
if (schedule && minute != _last_minute)
|
||||
{
|
||||
int block = _blockAt(minute);
|
||||
if (block != _block || _schedule->blocks[block].start == minute)
|
||||
{
|
||||
_picked = false;
|
||||
_item = 0;
|
||||
_item_since = now;
|
||||
}
|
||||
_block = block;
|
||||
}
|
||||
if (!schedule)
|
||||
_block = -1;
|
||||
_last_minute = minute;
|
||||
|
||||
// 1. Take-over rules
|
||||
int rule_count = _schedule ? _schedule->rule_count : 0;
|
||||
for (int i = 0; i < rule_count && choice.rule < 0; i++)
|
||||
{
|
||||
const TriggerRule &r = _schedule->rules[i];
|
||||
if (r.mode == TriggerMode::ForSeconds && _rule_timer[i] && now - _rule_since[i] >= r.seconds * 1000UL)
|
||||
_rule_timer[i] = false;
|
||||
bool takes_over = (r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
|
||||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i]);
|
||||
if (takes_over)
|
||||
{
|
||||
choice.page = r.page;
|
||||
choice.reason = ShowReason::Trigger;
|
||||
choice.rule = i;
|
||||
}
|
||||
}
|
||||
|
||||
// 2-4. The rotation: the block's pages (or the chosen page), then the AddToRotation pages
|
||||
if (choice.rule < 0)
|
||||
{
|
||||
struct Item
|
||||
{
|
||||
const char *page;
|
||||
uint32_t ms;
|
||||
ShowReason reason;
|
||||
int rule;
|
||||
} items[BLOCK_ITEMS + TRIGGER_RULES];
|
||||
int count = 0;
|
||||
if (schedule && !_picked)
|
||||
{
|
||||
const ScheduleBlock &b = _schedule->blocks[_block];
|
||||
effect = b.effect;
|
||||
for (int i = 0; i < b.item_count; i++)
|
||||
items[count++] = {b.items[i].page, b.items[i].seconds * 1000UL, ShowReason::Schedule, -1};
|
||||
}
|
||||
else
|
||||
items[count++] = {_chosen, CHOSEN_PAGE_SECONDS * 1000UL, ShowReason::Chosen, -1};
|
||||
for (int i = 0; i < rule_count; i++)
|
||||
{
|
||||
const TriggerRule &r = _schedule->rules[i];
|
||||
if (r.mode == TriggerMode::AddToRotation && _rule_active[i])
|
||||
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
|
||||
}
|
||||
|
||||
if (_item >= count)
|
||||
{
|
||||
_item = 0;
|
||||
_item_since = now;
|
||||
}
|
||||
if (now - _item_since >= items[_item].ms)
|
||||
{
|
||||
_item = (_item + 1) % count;
|
||||
_item_since = now;
|
||||
}
|
||||
choice.page = items[_item].page;
|
||||
choice.reason = items[_item].reason;
|
||||
choice.rule = items[_item].rule;
|
||||
}
|
||||
|
||||
choice.changed = !sameName(choice.page, _shown);
|
||||
// Rules bring their page at once, and leave at once; the schedule as set per block
|
||||
choice.instant = choice.changed && (choice.reason == ShowReason::Trigger || _shown_reason == ShowReason::Trigger ||
|
||||
!effect);
|
||||
copyName(_shown, choice.page);
|
||||
_shown_reason = choice.reason;
|
||||
return choice;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "logic/page_schedule.h"
|
||||
|
||||
// Decides which page or bitmap is shown, from (highest priority first):
|
||||
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order
|
||||
// 2. a manual pick (web UI, Home Assistant "Mode"), until the next schedule block starts
|
||||
// 3. the schedule: the pages of the current block rotate, plus the pages of active
|
||||
// AddToRotation rules
|
||||
// 4. without a schedule (none, or switched off): the chosen page, plus AddToRotation pages
|
||||
// Pure logic, tested in test/test_esp32. Times are millis().
|
||||
|
||||
enum class ShowReason : uint8_t
|
||||
{
|
||||
Chosen, // the chosen page (manual pick, or no schedule)
|
||||
Schedule, // a schedule block
|
||||
Rotation, // an AddToRotation rule
|
||||
Trigger, // a take-over rule
|
||||
};
|
||||
|
||||
struct PageChoice
|
||||
{
|
||||
const char *page = "";
|
||||
ShowReason reason = ShowReason::Chosen;
|
||||
int rule = -1; // the rule (Rotation, Trigger)
|
||||
bool changed = false; // another page than at the last update()
|
||||
bool instant = false; // when changed: switch without the refresh effect
|
||||
};
|
||||
|
||||
// The chosen page also rotates with AddToRotation pages, this long
|
||||
const uint16_t CHOSEN_PAGE_SECONDS = 30;
|
||||
|
||||
class PageSelector
|
||||
{
|
||||
public:
|
||||
// The schedule must stay valid (the selector keeps the pointer). Starts it fresh: ends a
|
||||
// manual pick, forgets the rule states.
|
||||
void setSchedule(const PageSchedule *schedule);
|
||||
// The chosen page (settings.page): shown without a schedule, and after pick()
|
||||
void setChosen(const char *page);
|
||||
// A manual pick: setChosen() and, when a schedule runs, show it until the next block
|
||||
void pick(const char *page);
|
||||
void backToSchedule();
|
||||
// A manual pick overrides the schedule now
|
||||
bool picked() const { return _picked; }
|
||||
// The schedule is used (switched on, with blocks)
|
||||
bool scheduleActive() const;
|
||||
// Start of the block that ends a manual pick (minutes since midnight), -1 when none
|
||||
int pickedUntil() const;
|
||||
|
||||
// Whether a rule's condition is true now (from Home Assistant)
|
||||
void setRuleState(int rule, bool active, uint32_t now);
|
||||
bool ruleActive(int rule) const;
|
||||
|
||||
PageChoice update(int minute_of_day, uint32_t now);
|
||||
|
||||
private:
|
||||
const PageSchedule *_schedule = nullptr;
|
||||
char _chosen[PAGE_NAME_SIZE] = "";
|
||||
bool _picked = false;
|
||||
int _block = -1;
|
||||
int _last_minute = -1;
|
||||
int _item = 0;
|
||||
uint32_t _item_since = 0;
|
||||
bool _rule_active[TRIGGER_RULES] = {};
|
||||
bool _rule_timer[TRIGGER_RULES] = {}; // ForSeconds runs
|
||||
uint32_t _rule_since[TRIGGER_RULES] = {};
|
||||
char _shown[PAGE_NAME_SIZE] = "";
|
||||
ShowReason _shown_reason = ShowReason::Chosen;
|
||||
|
||||
int _blockAt(int minute_of_day) const;
|
||||
};
|
||||
Reference in new issue
Block a user