diff --git a/README.md b/README.md index 43effb4..48651ca 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/src/esp32/config.h b/src/esp32/config.h index 7573743..2c3900f 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -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}; diff --git a/src/esp32/logic/page_schedule.cpp b/src/esp32/logic/page_schedule.cpp index d52b17d..edd8487 100644 --- a/src/esp32/logic/page_schedule.cpp +++ b/src/esp32/logic/page_schedule.cpp @@ -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) diff --git a/src/esp32/logic/page_schedule.h b/src/esp32/logic/page_schedule.h index c46871f..5660e9b 100644 --- a/src/esp32/logic/page_schedule.h +++ b/src/esp32/logic/page_schedule.h @@ -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); diff --git a/src/esp32/logic/page_selector.cpp b/src/esp32/logic/page_selector.cpp index f10493e..299672a 100644 --- a/src/esp32/logic/page_selector.cpp +++ b/src/esp32/logic/page_selector.cpp @@ -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}; } diff --git a/src/esp32/logic/page_selector.h b/src/esp32/logic/page_selector.h index 321b825..287d8a4 100644 --- a/src/esp32/logic/page_selector.h +++ b/src/esp32/logic/page_selector.h @@ -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 diff --git a/src/esp32/system/schedule_store.cpp b/src/esp32/system/schedule_store.cpp index 95656b1..28bcf5f 100644 --- a/src/esp32/system/schedule_store.cpp +++ b/src/esp32/system/schedule_store.cpp @@ -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,18 +15,92 @@ 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; + 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(); - if (read == sizeof(StoredSchedule) && stored->version == VERSION && stored->size == sizeof(PageSchedule) && - validateSchedule(stored->schedule) == nullptr) - schedule = stored->schedule; - delete stored; } void saveSchedule(const PageSchedule &schedule) diff --git a/src/esp32/web/schedule_json.cpp b/src/esp32/web/schedule_json.cpp index cabd159..076581a 100644 --- a/src/esp32/web/schedule_json.cpp +++ b/src/esp32/web/schedule_json.cpp @@ -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(); + 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()) + { + 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); } diff --git a/src/esp32/web/schedule_json.h b/src/esp32/web/schedule_json.h index c34b03c..e42c258 100644 --- a/src/esp32/web/schedule_json.h +++ b/src/esp32/web/schedule_json.h @@ -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()). diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 34fbc3f..66e6f44 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -98,7 +98,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m

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.

Rules

-

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.

+

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).

Stopwatch

@@ -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 diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 07f44db..acdb81f 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -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);