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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user