diff --git a/Pictures/web-ui/schedule.png b/Pictures/web-ui/schedule.png index d7ab068..2c7202d 100644 Binary files a/Pictures/web-ui/schedule.png and b/Pictures/web-ui/schedule.png differ diff --git a/docs/home-assistant.md b/docs/home-assistant.md index b21ed2c..5fb3f7a 100644 --- a/docs/home-assistant.md +++ b/docs/home-assistant.md @@ -26,7 +26,8 @@ The ESP32 connects to Home Assistant in two ways: - `FlipDot Display Slow mode` switch and `FlipDot Display Slow mode interval` dropdown (`1 minute` ... `60 minutes`): the display only changes at those times, on the clock, and is switched off in between (less noise, e.g. in the evening with an automation). Rules - with "show at once in slow mode" still show their page right away. + with "show at once in slow mode" still show their page right away; pages with "skip in + slow mode" are left out of the rotation. - `FlipDot Display Firmware` sensor: version and health, e.g. `2.7.2, ok, 0 crashes, recovery ok`. - `FlipDot Display Recovery` button: restarts into the [recovery app](recovery.md) for 10 minutes. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f469949..a87ed02 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -26,6 +26,8 @@ :00, :15, :30 and :45). 30 seconds after each update the relay turns the display off; the dots keep the image. Page changes wait for the next update too, except for rules with "show at once in slow mode": while their page is shown, the display works normally. + Schedule pages and rotation rules with "skip in slow mode" are left out of the rotation + (none left in the block: the chosen page). - **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI) and power is cut after 30 seconds. 3. **Home Assistant**: switches, dropdowns and sensors over MQTT, see diff --git a/docs/web-ui.md b/docs/web-ui.md index fc26c30..3a62ad0 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -70,7 +70,11 @@ interval (2-600 s) and can be limited to some of the blocks. The first rule that page wins. A rule can leave a page chosen by hand alone (untick "also over a page chosen by hand"): it then waits until that pick ends (the next block or "Back to schedule"). A rule with "show at once in slow mode" ticked brings its page right away in -[slow mode](#settings), and the display works normally while that page is shown. The display schedule ([Settings](#settings)) still decides when the display is on. +[slow mode](#settings), and the display works normally while that page is shown. + +Pages in a block, and rules that add their page to the rotation, can be left out while slow +mode is on ("skip in slow mode"). When that leaves none of a block's pages, the chosen page +is shown instead. The display schedule ([Settings](#settings)) still decides when the display is on. ![Schedule](../Pictures/web-ui/schedule.png) diff --git a/src/esp32/app.cpp b/src/esp32/app.cpp index 447112a..0b318c3 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -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)) diff --git a/src/esp32/config.h b/src/esp32/config.h index 56811f3..154d9dc 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -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}; diff --git a/src/esp32/logic/page_schedule.h b/src/esp32/logic/page_schedule.h index dce253b..f39fe64 100644 --- a/src/esp32/logic/page_schedule.h +++ b/src/esp32/logic/page_schedule.h @@ -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 diff --git a/src/esp32/logic/page_selector.cpp b/src/esp32/logic/page_selector.cpp index b8d5601..bd48c41 100644 --- a/src/esp32/logic/page_selector.cpp +++ b/src/esp32/logic/page_selector.cpp @@ -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}; } diff --git a/src/esp32/logic/page_selector.h b/src/esp32/logic/page_selector.h index bb576f7..c791fcd 100644 --- a/src/esp32/logic/page_selector.h +++ b/src/esp32/logic/page_selector.h @@ -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; diff --git a/src/esp32/system/schedule_store.cpp b/src/esp32/system/schedule_store.cpp index 6f37ce1..ae41349 100644 --- a/src/esp32/system/schedule_store.cpp +++ b/src/esp32/system/schedule_store.cpp @@ -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(); } diff --git a/src/esp32/web/schedule_json.cpp b/src/esp32/web/schedule_json.cpp index 00b8c6a..a137513 100644 --- a/src/esp32/web/schedule_json.cpp +++ b/src/esp32/web/schedule_json.cpp @@ -44,6 +44,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out) JsonObject item = items.add(); 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(); @@ -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(); 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()) { diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 97aac74..b15dab1 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -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){}} diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index e628c03..0514681 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -2122,6 +2122,41 @@ void test_selector_add_to_rotation() TEST_ASSERT_EQUAL_STRING("3D printer", sel2.update(600, CHOSEN_PAGE_SECONDS * 1000).page); } +void test_selector_skips_pages_in_slow_mode() +{ + PageSchedule s = twoBlocks(); + s.blocks[0].items[1].skip_slow_mode = true; // Datum + s.rule_count = 2; + s.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15); + s.rules[0].skip_slow_mode = true; + s.rules[1] = rule("Afval", TriggerMode::AddToRotation, 15); + PageSelector sel; + sel.setSchedule(&s); + sel.setChosen("Clock"); + sel.setRuleState(0, true, 0); + sel.setRuleState(1, true, 0); + sel.setSlowMode(true); + TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 0).page); + TEST_ASSERT_EQUAL_STRING("Afval", sel.update(8 * 60, 20000).page); // no Datum, no printer + TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 35000).page); + + // Off again: all of them + sel.setSlowMode(false); + TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 55000).page); + TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 65000).page); + + // All of the block's pages skipped: the chosen page, plus the rules that stay + s.blocks[0].items[0].skip_slow_mode = true; + sel.setRuleState(1, false, 70000); + sel.setSlowMode(true); + PageChoice c = sel.update(8 * 60, 80000); + TEST_ASSERT_EQUAL_STRING("Clock", c.page); + TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen); + TEST_ASSERT_EQUAL_STRING("Clock", sel.update(8 * 60, 200000).page); + TEST_ASSERT_FALSE(ScheduleItem().skip_slow_mode); + TEST_ASSERT_FALSE(TriggerRule().skip_slow_mode); +} + // --------------------------------------------------------------------------- // Home Assistant value conversions // --------------------------------------------------------------------------- @@ -3271,6 +3306,7 @@ int main() RUN_TEST(test_selector_rule_over_slow_mode); RUN_TEST(test_selector_rule_can_leave_a_manual_pick_alone); RUN_TEST(test_selector_add_to_rotation); + RUN_TEST(test_selector_skips_pages_in_slow_mode); RUN_TEST(test_ha_auto_unit_and_absolute); RUN_TEST(test_ha_duration); RUN_TEST(test_ha_timestamps);