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

+229
View File
@@ -20,6 +20,8 @@
#include "logic/display_controller.h"
#include "logic/page.h"
#include "logic/page_render.h"
#include "logic/page_schedule.h"
#include "logic/page_selector.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/settings.h"
@@ -1518,6 +1520,225 @@ void test_moving_page_is_redrawn_when_due()
TEST_ASSERT_EQUAL(3, (int)frames.shown.size());
}
// ---------------------------------------------------------------------------
// Page schedule, rules and the page selector
// ---------------------------------------------------------------------------
static void setItem(ScheduleBlock &b, const char *page, uint16_t seconds)
{
strcpy(b.items[b.item_count].page, page);
b.items[b.item_count].seconds = seconds;
b.item_count++;
}
// 07:00 Weer (20 s) + Datum (10 s), with effect; 17:00 Energie, instant
static PageSchedule twoBlocks()
{
PageSchedule s;
s.block_count = 2;
s.blocks[0].start = 7 * 60;
setItem(s.blocks[0], "Weer", 20);
setItem(s.blocks[0], "Datum", 10);
s.blocks[1].start = 17 * 60;
s.blocks[1].effect = false;
setItem(s.blocks[1], "Energie", 30);
return s;
}
static TriggerRule rule(const char *page, TriggerMode mode, uint16_t seconds = 30)
{
TriggerRule r;
strcpy(r.entity, "sensor.printer");
strcpy(r.value, "printing");
strcpy(r.page, page);
r.mode = mode;
r.seconds = seconds;
return r;
}
void test_schedule_validation()
{
PageSchedule s = twoBlocks();
TEST_ASSERT_NULL(validateSchedule(s));
s.blocks[1].start = 7 * 60;
TEST_ASSERT_NOT_NULL(validateSchedule(s)); // same time
s = twoBlocks();
s.blocks[0].items[0].seconds = 4;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
s.blocks[1].item_count = 0;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
s.rule_count = 1;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
TEST_ASSERT_NULL(validateSchedule(s));
s.rules[0].interval = 1;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
strcpy(s.rules[0].entity, "printer");
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s.rules[0] = rule("", TriggerMode::WhileTrue);
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
TEST_ASSERT_TRUE(scheduleUses(s, "datum"));
TEST_ASSERT_FALSE(scheduleUses(s, "Clock"));
}
void test_rule_comparisons()
{
TriggerRule r = rule("x", TriggerMode::WhileTrue);
TEST_ASSERT_TRUE(ruleMatches(r, "Printing"));
TEST_ASSERT_FALSE(ruleMatches(r, "standby"));
TEST_ASSERT_FALSE(ruleMatches(r, nullptr));
r.compare = Compare::NotEqual;
TEST_ASSERT_TRUE(ruleMatches(r, "standby"));
TEST_ASSERT_FALSE(ruleMatches(r, nullptr)); // unknown is never true
r.compare = Compare::Above;
strcpy(r.value, "1000");
TEST_ASSERT_TRUE(ruleMatches(r, "1257"));
TEST_ASSERT_FALSE(ruleMatches(r, "999.5"));
TEST_ASSERT_FALSE(ruleMatches(r, "unavailable"));
r.compare = Compare::Below;
TEST_ASSERT_TRUE(ruleMatches(r, "-3"));
}
void test_selector_without_schedule_shows_the_chosen_page()
{
PageSelector sel;
sel.setChosen("Clock");
PageChoice c = sel.update(600, 0);
TEST_ASSERT_EQUAL_STRING("Clock", c.page);
TEST_ASSERT_TRUE(c.changed);
TEST_ASSERT_FALSE(sel.update(601, 100000).changed);
sel.pick("Weer");
TEST_ASSERT_FALSE(sel.picked()); // no schedule: just the new chosen page
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(601, 100100).page);
}
void test_selector_rotates_the_block()
{
PageSchedule s = twoBlocks();
PageSelector sel;
sel.setSchedule(&s);
sel.setChosen("Clock");
PageChoice c = sel.update(8 * 60, 0);
TEST_ASSERT_EQUAL_STRING("Weer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Schedule);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 19999).page);
c = sel.update(8 * 60, 20000);
TEST_ASSERT_EQUAL_STRING("Datum", c.page);
TEST_ASSERT_TRUE(c.changed);
TEST_ASSERT_FALSE(c.instant); // this block uses the effect
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60 + 1, 30000).page);
c = sel.update(17 * 60, 31000); // the next block
TEST_ASSERT_EQUAL_STRING("Energie", c.page);
TEST_ASSERT_TRUE(c.instant);
// Before 07:00: the last block of the day before
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(3 * 60, 40000).page);
}
void test_selector_manual_pick_until_the_next_block()
{
PageSchedule s = twoBlocks();
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Grote klok");
TEST_ASSERT_TRUE(sel.picked());
TEST_ASSERT_EQUAL(17 * 60, sel.pickedUntil());
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("Grote klok", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
TEST_ASSERT_EQUAL_STRING("Grote klok", sel.update(16 * 60 + 59, 900000).page);
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 901000).page);
TEST_ASSERT_FALSE(sel.picked());
sel.pick("Grote klok");
sel.backToSchedule();
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60 + 1, 902000).page);
}
void test_selector_one_block_starts_again_each_day()
{
PageSchedule s = twoBlocks();
s.block_count = 1;
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Clock");
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(6 * 60 + 59, 1000).page); // next morning, before 07:00
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(7 * 60, 2000).page);
s.enabled = false; // switched off: the chosen page
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(7 * 60 + 1, 3000).page);
}
void test_selector_take_over_rules()
{
PageSchedule s = twoBlocks();
s.rule_count = 2;
s.rules[0] = rule("Deurbel", TriggerMode::ForSeconds, 30);
s.rules[1] = rule("3D printer", TriggerMode::WhileTrue);
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Clock"); // a rule beats a manual pick
sel.setRuleState(1, true, 1000);
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Trigger);
TEST_ASSERT_EQUAL(1, c.rule);
TEST_ASSERT_TRUE(c.instant);
sel.setRuleState(0, true, 2000); // the first rule wins
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 2000).page);
sel.setRuleState(0, true, 20000); // still true: no restart
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 31999).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 32000).page); // 30 s over
sel.setRuleState(0, false, 33000);
sel.setRuleState(0, true, 34000); // true again: 30 s again
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 34000).page);
sel.setRuleState(0, false, 70000);
sel.setRuleState(1, false, 70000);
c = sel.update(8 * 60, 70000);
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // the manual pick again
TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once
}
void test_selector_add_to_rotation()
{
PageSchedule s = twoBlocks();
s.rule_count = 1;
s.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 0).page);
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 20000).page);
PageChoice c = sel.update(8 * 60, 30000);
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Rotation);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 45000).page);
sel.setRuleState(0, false, 46000);
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 65000).page);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 75000).page); // printer gone
// Without a schedule the chosen page rotates with it
PageSchedule rules_only;
rules_only.rule_count = 1;
rules_only.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
PageSelector sel2;
sel2.setSchedule(&rules_only);
sel2.setChosen("Clock");
sel2.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Clock", sel2.update(600, 0).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel2.update(600, CHOSEN_PAGE_SECONDS * 1000).page);
}
int main()
{
UNITY_BEGIN();
@@ -1627,6 +1848,14 @@ int main()
RUN_TEST(test_render_asks_for_the_next_second);
RUN_TEST(test_image_element_by_state);
RUN_TEST(test_moving_page_is_redrawn_when_due);
RUN_TEST(test_schedule_validation);
RUN_TEST(test_rule_comparisons);
RUN_TEST(test_selector_without_schedule_shows_the_chosen_page);
RUN_TEST(test_selector_rotates_the_block);
RUN_TEST(test_selector_manual_pick_until_the_next_block);
RUN_TEST(test_selector_one_block_starts_again_each_day);
RUN_TEST(test_selector_take_over_rules);
RUN_TEST(test_selector_add_to_rotation);
return UNITY_END();
}