Schedule pages and rotation rules can be skipped in slow mode

Each page in a schedule block, and each rule that adds its page to the
rotation, has a "skip in slow mode" checkbox. While slow mode is on those
pages are left out of the rotation; when none of the block's pages are
left, the chosen page is shown. The rotation keeps its own timers.

Schedule format version 5; version 4 is converted when read and stored
again in version 5 on the next save.

Version 2.18.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-20 21:14:14 +02:00
1 parent a91d2550d7
commit e9f3c16c78
13 files changed
+177 -13

No files matched your search

+1
View File
@@ -277,6 +277,7 @@ void appPageLoop(bool time_valid, const LocalTime &time)
time_known = time_valid;
if (!time_valid)
return;
selector.setSlowMode(settings.slow_mode.enabled);
shown_choice = selector.update(time.hour * 60 + time.minute, now);
controller.setSlowModeOverride(shown_choice.over_slow_mode);
if (!shown_choice.changed && sameName(shown, shown_choice.page))
+1 -1
View File
@@ -118,7 +118,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.17.0"
#define FIRMWARE_VERSION "2.18.0"
// 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};
+2
View File
@@ -25,6 +25,7 @@ struct ScheduleItem
{
char page[PAGE_NAME_SIZE] = "";
uint16_t seconds = 30;
bool skip_slow_mode = false; // left out of the rotation while slow mode is on
};
// From `start` (minutes since midnight) until the next block
@@ -66,6 +67,7 @@ struct TriggerRule
uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule)
bool over_manual = true; // also over a page picked by hand (false: it waits until the pick ends)
bool over_slow_mode = false; // while its page is shown the display works normally (slow mode waits)
bool skip_slow_mode = false; // AddToRotation: left out of the rotation while slow mode is on
};
struct PageSchedule
+4 -3
View File
@@ -131,15 +131,16 @@ PageChoice PageSelector::update(int minute, uint32_t now)
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};
if (!_slow || !b.items[i].skip_slow_mode)
items[count++] = {b.items[i].page, b.items[i].seconds * 1000UL, ShowReason::Schedule, -1};
}
else
if (count == 0) // no schedule, a manual pick, or slow mode skips all the block's pages
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] && ruleInBlock(r, _block) &&
(r.over_manual || !_picked))
(r.over_manual || !_picked) && (!_slow || !r.skip_slow_mode))
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
}
+6
View File
@@ -12,6 +12,8 @@
// 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
// In slow mode the items and AddToRotation rules with skip_slow_mode are left out of the
// rotation; when that leaves none of the block's pages, the chosen page is shown instead.
// Pure logic, tested in test/test_esp32. Times are millis().
enum class ShowReason : uint8_t
@@ -53,6 +55,9 @@ public:
// Start of the block that ends a manual pick (minutes since midnight), -1 when none
int pickedUntil() const;
// Slow mode is switched on (SlowModeSettings::enabled): leave out the skip_slow_mode pages
void setSlowMode(bool on) { _slow = on; }
// 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;
@@ -63,6 +68,7 @@ private:
const PageSchedule *_schedule = nullptr;
char _chosen[PAGE_NAME_SIZE] = "";
bool _picked = false;
bool _slow = false;
int _block = -1;
int _last_minute = -1;
int _item = 0;
+107 -3
View File
@@ -6,9 +6,9 @@
static const char *NAMESPACE = "flipdot";
static const char *KEY = "schedule";
// Increase when PageSchedule changes; records of other versions are ignored (an empty
// schedule). 2.15.0 stored all older records again in version 4. To keep the schedule over
// a change, add a frozen copy of the old layout and convert it (see git history).
static const uint16_t VERSION = 4;
// schedule) unless converted below. To keep the schedule over a change, add a frozen copy
// of the old layout and convert it.
static const uint16_t VERSION = 5;
struct StoredSchedule
{
@@ -17,6 +17,96 @@ struct StoredSchedule
PageSchedule schedule;
};
// Version 4 (2.15.0 - 2.17.0): no skip_slow_mode. Frozen copy, converted when read; the
// converted schedule is only stored when it is saved again (a rollback can still read
// version 4). Drop this once the display has saved its schedule in version 5.
namespace v4
{
struct Item
{
char page[PAGE_NAME_SIZE];
uint16_t seconds;
};
struct Block
{
uint16_t start;
bool effect;
uint8_t item_count;
Item items[BLOCK_ITEMS];
};
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;
uint8_t blocks;
bool over_manual;
bool over_slow_mode;
};
struct Schedule
{
bool enabled;
uint8_t block_count;
Block 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(); // skip_slow_mode: false
s.enabled = old.enabled;
s.block_count = old.block_count;
for (int i = 0; i < SCHEDULE_BLOCKS; i++)
{
const Block &o = old.blocks[i];
ScheduleBlock &b = s.blocks[i];
b.start = o.start;
b.effect = o.effect;
b.item_count = o.item_count;
for (int j = 0; j < BLOCK_ITEMS; j++)
{
memcpy(b.items[j].page, o.items[j].page, sizeof(b.items[j].page));
b.items[j].seconds = o.items[j].seconds;
}
}
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;
r.blocks = o.blocks;
r.over_manual = o.over_manual;
r.over_slow_mode = o.over_slow_mode;
}
}
} // namespace v4
static_assert(sizeof(v4::Stored) != sizeof(StoredSchedule), "the record sizes tell the versions apart");
void loadSchedule(PageSchedule &schedule)
{
schedule = PageSchedule();
@@ -32,6 +122,20 @@ void loadSchedule(PageSchedule &schedule)
schedule = stored->schedule;
delete stored;
}
else if (length == sizeof(v4::Stored))
{
v4::Stored *stored = new v4::Stored;
PageSchedule *converted = new PageSchedule;
if (prefs.getBytes(KEY, stored, sizeof(v4::Stored)) == sizeof(v4::Stored) && stored->version == 4 &&
stored->size == sizeof(v4::Schedule))
{
v4::convert(stored->schedule, *converted);
if (validateSchedule(*converted) == nullptr)
schedule = *converted;
}
delete converted;
delete stored;
}
prefs.end();
}
+4
View File
@@ -44,6 +44,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
JsonObject item = items.add<JsonObject>();
item["page"] = b.items[j].page;
item["seconds"] = b.items[j].seconds;
item["skip_slow_mode"] = b.items[j].skip_slow_mode;
}
}
JsonArray rules = out["rules"].to<JsonArray>();
@@ -61,6 +62,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
o["interval"] = r.interval;
o["over_manual"] = r.over_manual;
o["over_slow_mode"] = r.over_slow_mode;
o["skip_slow_mode"] = r.skip_slow_mode;
JsonArray in_blocks = o["blocks"].to<JsonArray>();
for (int b = 0; b < s.block_count; b++)
if ((r.blocks >> b) & 1)
@@ -97,6 +99,7 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
if (!copyText(i["page"], item.page, sizeof(item.page)))
return "Page name too long";
item.seconds = constrain(i["seconds"] | 30, 0, 65535);
item.skip_slow_mode = i["skip_slow_mode"] | false;
}
}
@@ -115,6 +118,7 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
r.interval = constrain(o["interval"] | 10, 0, 65535);
r.over_manual = o["over_manual"] | true;
r.over_slow_mode = o["over_slow_mode"] | false;
r.skip_slow_mode = o["skip_slow_mode"] | false;
// The blocks by their start time (none: always)
for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>())
{
+7 -4
View File
@@ -330,24 +330,27 @@ function pageOpts(){return pageList.map(p=>[p.name,p.name]).concat(bitmapList.ma
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 skipSlow(o){const c=mk('input',{type:'checkbox',checked:!!o.skip_slow_mode});c.onchange=()=>{o.skip_slow_mode=c.checked;schedDirty=true};
return mk('label',{title:'Left out of the rotation while slow mode is on'},c,' skip in slow mode')}
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'),
btn('Remove block',()=>{sched.blocks.splice(i,1);schedDirty=true;schedBuild()},'d')));
b.items.forEach((it,j)=>box.append(mk('div',{className:'row'},ssel(it,'page',opts),'for',sinp(it,'seconds','number',{style:'width:5em',min:5,max:3600}),'s',
btn('Remove',()=>{b.items.splice(j,1);schedDirty=true;schedBuild()},'d'))));
if(b.items.length<6)box.append(mk('div',{className:'row'},btn('+ Page',()=>{b.items.push({page:opts.length?opts[0][0]:'',seconds:30});schedDirty=true;schedBuild()})));
skipSlow(it),btn('Remove',()=>{b.items.splice(j,1);schedDirty=true;schedBuild()},'d'))));
if(b.items.length<6)box.append(mk('div',{className:'row'},btn('+ Page',()=>{b.items.push({page:opts.length?opts[0][0]:'',seconds:30,skip_slow_mode:false});schedDirty=true;schedBuild()})));
d.append(box)});
if(!sched.blocks.length)d.append(mk('p',{className:'hint',textContent:'No blocks: the chosen page is shown.'}));
const rd=$('sc_rules');rd.innerHTML='';
sched.rules.forEach((ru,i)=>{const state=mk('span',{className:'hint'});
const secs=mk('span',{},'for',sinp(ru,'seconds','number',{style:'width:5em',min:1,max:3600}),'s');secs.style.display=ru.mode=='while'?'none':'';
const skip=skipSlow(ru);skip.style.display=ru.mode=='rotate'?'':'none';
rd.append(mk('div',{className:'el'},
mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}),
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()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':'';skip.style.display=ru.mode=='rotate'?'':'none'}),secs,skip,
'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')),
mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:ru.over_manual!==false});c.onchange=()=>{ru.over_manual=c.checked;schedDirty=true};
return mk('label',{},c,' also over a page chosen by hand')})(),mk('span',{className:'hint',textContent:'(off: it waits until the chosen page ends: the next block or Back to schedule)'})),
@@ -360,7 +363,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
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,over_manual:true,over_slow_mode:false});schedDirty=true;schedBuild()}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true,over_slow_mode:false,skip_slow_mode:false});schedDirty=true;schedBuild()}
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){}}