Rules can be limited to schedule blocks

Each rule has the blocks it acts in (none: always; without a running
schedule always). Stored as a block mask (schedule format 2, version 1
records are converted), in JSON as the blocks' start times. Version 2.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 22:25:00 +02:00
1 parent 87e242bf89
commit 53e9057d02
11 files changed
+164 -17

No files matched your search

+1 -1
View File
@@ -96,7 +96,7 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
|-----|-----------------|
| **Show** | Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05`, `8:05`, optionally with seconds), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after; shown as a number with multiplier, decimals, auto unit `1257 W` → `1.3 kW` and optionally without minus sign, as a duration `2h 21m` / `2:21:00` / `2:21` / `2 uur`, as a time from an HA timestamp `23:07` / `do 23:07` / `15-10 23:07` / `over 34 min`, or with own texts per state `printing=Bezig, standby=Klaar`), text, countdown to a date and time (smart `12 dagen` / `5 uur` / `34 min` / `0:45`, days, `12d 04:05`, with seconds; a text when it has passed), stopwatch (start, stop and reset in the Show tab, the editor and Home Assistant) and image (a bitmap at a position, fixed or chosen by a Home Assistant state, e.g. `weather ` + `cloudy`). Texts can scroll in a window at a chosen speed, and can contain the degree sign. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts it. |
| **Schedule** | What is shown when, every day: blocks from a start time until the next block, each with up to 6 pages or bitmaps that rotate (each for its own time, 5 s to 1 hour), switching with the refresh effect or instantly. Rules: a Home Assistant state (entity or attribute, `=`, `is not`, `above`, `below`) that shows a page while it is true, adds it to the rotation while it is true, or shows it for a while when it becomes true; each rule is read at its own interval (2-600 s). A page chosen by hand (Show tab, HA "Mode") is shown until the next block starts; "Back to schedule" ends it earlier. The display schedule (Settings) still decides when the display is on. |
| **Schedule** | What is shown when, every day: blocks from a start time until the next block, each with up to 6 pages or bitmaps that rotate (each for its own time, 5 s to 1 hour), switching with the refresh effect or instantly. Rules: a Home Assistant state (entity or attribute, `=`, `is not`, `above`, `below`) that shows a page while it is true, adds it to the rotation while it is true, or shows it for a while when it becomes true; each rule is read at its own interval (2-600 s) and can be limited to some of the blocks. A page chosen by hand (Show tab, HA "Mode") is shown until the next block starts; "Back to schedule" ends it earlier. The display schedule (Settings) still decides when the display is on. |
| **Stopwatch** | Start, stop and reset the stopwatch that pages can show. |
| **Bitmaps** | All bitmaps with a preview (the ones that pages or rules use in their own subsection): show one as the page, send it live, open it in the editor, delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be deleted, nor can a bitmap that a page uses as background. |
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
+1 -1
View File
@@ -105,7 +105,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.7.0"
#define FIRMWARE_VERSION "2.7.1"
// 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};
+7
View File
@@ -60,6 +60,8 @@ const char *validateSchedule(const PageSchedule &s)
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";
if (r.blocks >> s.block_count)
return "A rule is set for a block that doesn't exist";
}
return nullptr;
}
@@ -79,6 +81,11 @@ static bool number(const char *text, double &value)
return end != text && *end == '\0';
}
bool ruleInBlock(const TriggerRule &rule, int block)
{
return rule.blocks == 0 || block < 0 || (rule.blocks >> block) & 1;
}
bool ruleMatches(const TriggerRule &rule, const char *value)
{
if (value == nullptr)
+4
View File
@@ -63,6 +63,7 @@ struct TriggerRule
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)
uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule)
};
struct PageSchedule
@@ -78,6 +79,9 @@ struct PageSchedule
// the pages exist.
const char *validateSchedule(const PageSchedule &schedule);
// Whether a rule acts during this block (-1: no schedule runs, then always)
bool ruleInBlock(const TriggerRule &rule, int block);
// Whether a rule's condition is true for this value (nullptr: not known, never true)
bool ruleMatches(const TriggerRule &rule, const char *value);
+4 -3
View File
@@ -104,8 +104,9 @@ PageChoice PageSelector::update(int minute, uint32_t now)
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]);
bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i])) &&
ruleInBlock(r, _block);
if (takes_over)
{
choice.page = r.page;
@@ -137,7 +138,7 @@ PageChoice PageSelector::update(int minute, uint32_t now)
for (int i = 0; i < rule_count; i++)
{
const TriggerRule &r = _schedule->rules[i];
if (r.mode == TriggerMode::AddToRotation && _rule_active[i])
if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block))
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
}
+2 -1
View File
@@ -5,7 +5,8 @@
#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
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be
// limited to some schedule blocks (TriggerRule::blocks)
// 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
+80 -6
View File
@@ -5,8 +5,8 @@
static const char *NAMESPACE = "flipdot";
static const char *KEY = "schedule";
// Increase when PageSchedule changes (older records are then ignored)
static const uint16_t VERSION = 1;
// Increase when PageSchedule changes; older records are ignored unless converted below
static const uint16_t VERSION = 2;
struct StoredSchedule
{
@@ -15,19 +15,93 @@ struct StoredSchedule
PageSchedule schedule;
};
// Version 1: the rules had no blocks (they were always active). Frozen copy, converted.
namespace v1
{
struct Rule
{
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
Compare compare;
char value[RULE_VALUE_SIZE];
char page[PAGE_NAME_SIZE];
TriggerMode mode;
uint16_t seconds;
uint16_t interval;
};
struct Schedule
{
bool enabled;
uint8_t block_count;
ScheduleBlock blocks[SCHEDULE_BLOCKS];
uint8_t rule_count;
Rule rules[TRIGGER_RULES];
};
struct Stored
{
uint16_t version;
uint16_t size;
Schedule schedule;
};
static void convert(const Schedule &old, PageSchedule &s)
{
s = PageSchedule();
s.enabled = old.enabled;
s.block_count = old.block_count;
memcpy(s.blocks, old.blocks, sizeof(s.blocks));
s.rule_count = old.rule_count;
for (int i = 0; i < TRIGGER_RULES; i++)
{
const Rule &o = old.rules[i];
TriggerRule &r = s.rules[i];
memcpy(r.entity, o.entity, sizeof(r.entity));
memcpy(r.attribute, o.attribute, sizeof(r.attribute));
r.compare = o.compare;
memcpy(r.value, o.value, sizeof(r.value));
memcpy(r.page, o.page, sizeof(r.page));
r.mode = o.mode;
r.seconds = o.seconds;
r.interval = o.interval;
}
}
} // namespace v1
static_assert(sizeof(v1::Stored) != sizeof(StoredSchedule), "the versions are told apart by their size");
void loadSchedule(PageSchedule &schedule)
{
schedule = PageSchedule();
StoredSchedule *stored = new StoredSchedule;
Preferences prefs;
prefs.begin(NAMESPACE, true);
size_t read = prefs.isKey(KEY) ? prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) : 0;
prefs.end();
if (read == sizeof(StoredSchedule) && stored->version == VERSION && stored->size == sizeof(PageSchedule) &&
size_t length = prefs.isKey(KEY) ? prefs.getBytesLength(KEY) : 0;
if (length == sizeof(StoredSchedule))
{
StoredSchedule *stored = new StoredSchedule;
if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) &&
stored->version == VERSION && stored->size == sizeof(PageSchedule) &&
validateSchedule(stored->schedule) == nullptr)
schedule = stored->schedule;
delete stored;
}
else if (length == sizeof(v1::Stored))
{
v1::Stored *stored = new v1::Stored;
PageSchedule *converted = new PageSchedule;
if (prefs.getBytes(KEY, stored, sizeof(v1::Stored)) == sizeof(v1::Stored) && stored->version == 1 &&
stored->size == sizeof(v1::Schedule))
{
v1::convert(stored->schedule, *converted);
if (validateSchedule(*converted) == nullptr)
schedule = *converted;
}
delete converted;
delete stored;
}
prefs.end();
}
void saveSchedule(const PageSchedule &schedule)
{
+20
View File
@@ -59,6 +59,13 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
o["mode"] = MODES[(int)r.mode];
o["seconds"] = r.seconds;
o["interval"] = r.interval;
JsonArray in_blocks = o["blocks"].to<JsonArray>();
for (int b = 0; b < s.block_count; b++)
if ((r.blocks >> b) & 1)
{
formatTime(s.blocks[b].start, time);
in_blocks.add(String(time));
}
}
}
@@ -104,6 +111,19 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
return "Rule text too long";
r.seconds = constrain(o["seconds"] | 30, 0, 65535);
r.interval = constrain(o["interval"] | 10, 0, 65535);
// The blocks by their start time (none: always)
for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>())
{
uint16_t minutes;
int found = -1;
if (parseTime(start | "", minutes))
for (int b = 0; b < s.block_count; b++)
if (s.blocks[b].start == minutes)
found = b;
if (found < 0)
return "A rule is set for a block that doesn't exist";
r.blocks |= 1 << found;
}
}
return validateSchedule(s);
}
+2 -1
View File
@@ -8,7 +8,8 @@
// {enabled,
// blocks: [{start: "07:00", effect: true, items: [{page, seconds}]}],
// rules: [{entity, attribute, compare: "eq"|"ne"|"gt"|"lt", value, page,
// mode: "while"|"rotate"|"for", seconds, interval}]}
// mode: "while"|"rotate"|"for", seconds, interval,
// blocks: ["07:00"] (the start times of the blocks it acts in; empty: always)}]}
void scheduleToJson(const PageSchedule &schedule, JsonObject out);
// Returns nullptr or a message for the user. Validates (validateSchedule()).
+10 -4
View File
@@ -98,7 +98,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on.</p></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div>
<button class="b" onclick="scSave()">Save schedule and rules</button></section>
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
@@ -267,7 +267,10 @@ function sinp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text
function ssel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));if(!opts.some(x=>x[0]==o[k]))s.append(mk('option',{value:o[k],textContent:o[k]+' (gone)'}));
s.value=o[k];s.onchange=()=>{o[k]=s.value;schedDirty=true;if(after)after()};return s}
function pageOpts(){return pageList.map(p=>[p.name,p.name]).concat(bitmapList.map(b=>[b.name,b.name+' (bitmap)']))}
function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
function schedLink(){sched.rules.forEach(r=>{if(!r._b)r._b=(r.blocks||[]).map(t=>sched.blocks.find(b=>b.start==t)).filter(Boolean)})}
function schedPayload(){return{enabled:sched.enabled,blocks:sched.blocks,rules:sched.rules.map(r=>{const o=Object.assign({},r);
o.blocks=sched.blocks.filter(b=>(r._b||[]).includes(b)).map(b=>b.start);delete o._b;return o})}}
function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
sched.blocks.forEach((b,i)=>{const t=sinp(b,'start','time');t.onchange=()=>{sched.blocks.sort(bySlot);schedDirty=true;schedBuild()};
const ef=mk('input',{type:'checkbox',checked:b.effect});ef.onchange=()=>{b.effect=ef.checked;schedDirty=true};
const box=mk('div',{className:'el'},mk('div',{className:'row'},mk('b',{textContent:'From'}),t,mk('label',{},ef,' switch with the refresh effect'),
@@ -285,13 +288,16 @@ function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=
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})),
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'))))});
'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)});
c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}),
mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))});
if(!sched.rules.length)rd.append(mk('p',{className:'hint',textContent:'No rules.'}))}
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',sched);schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
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
+33
View File
@@ -1708,6 +1708,38 @@ void test_selector_take_over_rules()
TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once
}
void test_selector_rules_only_in_their_blocks()
{
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
s.rule_count = 2;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
s.rules[0].blocks = 1 << 1; // only from 17:00
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
s.rules[1].blocks = 1 << 0; // only in the morning block
TEST_ASSERT_NULL(validateSchedule(s));
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 1000).page); // not in this block
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 2000).page);
sel.setRuleState(0, false, 3000);
sel.setRuleState(1, true, 3000);
sel.update(17 * 60, 3000);
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 40000).page); // not added here
// Without a running schedule the blocks don't matter
s.enabled = false;
sel.setRuleState(0, true, 50000);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 50000).page);
s.enabled = true;
s.rules[0].blocks = 1 << 2; // a third block doesn't exist
TEST_ASSERT_NOT_NULL(validateSchedule(s));
TEST_ASSERT_TRUE(ruleInBlock(s.rules[1], -1));
TEST_ASSERT_FALSE(ruleInBlock(s.rules[1], 1));
}
void test_selector_add_to_rotation()
{
PageSchedule s = twoBlocks();
@@ -1984,6 +2016,7 @@ int main()
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_rules_only_in_their_blocks);
RUN_TEST(test_selector_add_to_rotation);
RUN_TEST(test_ha_auto_unit_and_absolute);
RUN_TEST(test_ha_duration);