diff --git a/CLAUDE.md b/CLAUDE.md index 3932cf3..bc51209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,8 +164,13 @@ See README.md for the overview. versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage` changes). They are drawn only on the ESP32 (`logic/page_render`), also for the web previews, so there is one renderer. Pages and bitmaps share one namespace (checked in - `app.cpp`); things a page uses can't be deleted. The built-in page "Clock" replaces the - old hardcoded clock (also the default and the HA "Clock" option). + `app.cpp`); things a page uses can't be deleted. The page "Clock" replaces the + old hardcoded clock (also the default and the HA "Clock" option). `pageStoreBegin()` + stores a copy of each built-in page (`logic/builtin_pages`) at boot when none is stored, + so it can be edited; the built-in one is the fallback, and deleting the copy resets it. +- `ha_api` attribute lookups: cast `char` arrays to `const char *` for ArduinoJson keys. + ArduinoJson 7.4 takes a `char[N]` as a literal of length N-1, which never matches (that + broke all attributes from 2.9.0 to 2.10.0). - Moving pages: `renderPage()` returns when the page changes next (scroll step, second); `FlipdotFrames` sends those frames as `D1` (no effect) and paces them by the estimated receive and flip time, because the Nano has no acknowledgement. Full screen frames go diff --git a/Pictures/pages/3d-printer-finished.png b/Pictures/pages/3d-printer-finished.png index 3833bc8..497344c 100644 Binary files a/Pictures/pages/3d-printer-finished.png and b/Pictures/pages/3d-printer-finished.png differ diff --git a/Pictures/pages/3d-printer.png b/Pictures/pages/3d-printer.png index bd29687..966e521 100644 Binary files a/Pictures/pages/3d-printer.png and b/Pictures/pages/3d-printer.png differ diff --git a/Pictures/pages/bad.png b/Pictures/pages/bad.png new file mode 100644 index 0000000..9466cf9 Binary files /dev/null and b/Pictures/pages/bad.png differ diff --git a/Pictures/pages/binnen.png b/Pictures/pages/binnen.png index d74f7cd..8986c0f 100644 Binary files a/Pictures/pages/binnen.png and b/Pictures/pages/binnen.png differ diff --git a/Pictures/pages/energie.png b/Pictures/pages/energie.png index eae3a20..cbab0f7 100644 Binary files a/Pictures/pages/energie.png and b/Pictures/pages/energie.png differ diff --git a/Pictures/pages/plex.png b/Pictures/pages/plex.png index 5a08d3f..71c2016 100644 Binary files a/Pictures/pages/plex.png and b/Pictures/pages/plex.png differ diff --git a/Pictures/pages/verbruik.png b/Pictures/pages/verbruik.png index 27da82f..e5b8f12 100644 Binary files a/Pictures/pages/verbruik.png and b/Pictures/pages/verbruik.png differ diff --git a/Pictures/pages/weer.png b/Pictures/pages/weer.png index 95ed335..68abfcc 100644 Binary files a/Pictures/pages/weer.png and b/Pictures/pages/weer.png differ diff --git a/Pictures/web-ui/bitmaps.png b/Pictures/web-ui/bitmaps.png index dc87fe3..ea0b7f6 100644 Binary files a/Pictures/web-ui/bitmaps.png and b/Pictures/web-ui/bitmaps.png differ diff --git a/Pictures/web-ui/draw.png b/Pictures/web-ui/draw.png index 7554dfc..831d6c6 100644 Binary files a/Pictures/web-ui/draw.png and b/Pictures/web-ui/draw.png differ diff --git a/Pictures/web-ui/home-assistant.png b/Pictures/web-ui/home-assistant.png index 72e5346..2caee2b 100644 Binary files a/Pictures/web-ui/home-assistant.png and b/Pictures/web-ui/home-assistant.png differ diff --git a/Pictures/web-ui/icons.png b/Pictures/web-ui/icons.png index c247e4d..e455a41 100644 Binary files a/Pictures/web-ui/icons.png and b/Pictures/web-ui/icons.png differ diff --git a/Pictures/web-ui/pages.png b/Pictures/web-ui/pages.png index 83724ba..d639e0d 100644 Binary files a/Pictures/web-ui/pages.png and b/Pictures/web-ui/pages.png differ diff --git a/Pictures/web-ui/schedule.png b/Pictures/web-ui/schedule.png index b180adf..842e819 100644 Binary files a/Pictures/web-ui/schedule.png and b/Pictures/web-ui/schedule.png differ diff --git a/Pictures/web-ui/settings.png b/Pictures/web-ui/settings.png index 484428c..9d7889e 100644 Binary files a/Pictures/web-ui/settings.png and b/Pictures/web-ui/settings.png differ diff --git a/Pictures/web-ui/show.png b/Pictures/web-ui/show.png index a05af70..aa2f99a 100644 Binary files a/Pictures/web-ui/show.png and b/Pictures/web-ui/show.png differ diff --git a/Pictures/web-ui/stopwatch.png b/Pictures/web-ui/stopwatch.png index 2431dbc..ff8219b 100644 Binary files a/Pictures/web-ui/stopwatch.png and b/Pictures/web-ui/stopwatch.png differ diff --git a/Pictures/web-ui/system.png b/Pictures/web-ui/system.png index b4942e8..b0b2ef7 100644 Binary files a/Pictures/web-ui/system.png and b/Pictures/web-ui/system.png differ diff --git a/docs/web-ui.md b/docs/web-ui.md index 9a93825..d0579b3 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -25,7 +25,8 @@ Bitmaps that pages or rules use are in their own subsection. Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it is exactly what the display shows; drag the elements on it, or select one and use the -arrow keys. Built-in pages can be copied, not changed. See the [pictures of the pages](pages.md). +arrow keys. The Clock page (Pikachu, the default) is a normal page that can be changed; +its Reset button brings back the original. See the [pictures of the pages](pages.md). | Element | Options | |---------|---------| @@ -57,7 +58,8 @@ Rules bring a page to the front from a Home Assistant state (an entity or attrib `is not`, `above`, `below`): they show it while the state is true, add it to the rotation while it is true, or show 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. The first rule that shows a -page wins. The display schedule ([Settings](#settings)) still decides when the display is on. +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"). 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 8fd768c..8d7bdeb 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -335,11 +335,19 @@ const char *appSavePage(const char *name, const DynamicPage &page) const char *appDeletePage(const char *name) { - if (scheduleUses(page_schedule, name)) + // A stored copy of a built-in page: deleting it resets the page, which stays + bool resets = builtinPageName(name) != nullptr; + if (!resets && scheduleUses(page_schedule, name)) return USED_BY_SCHEDULE; const char *error = deletePage(name); if (error) return error; + if (resets) + { + if (sameName(shown, name)) + reloadPage(); + return nullptr; + } if (sameName(settings.page, name)) setChosen(DEFAULT_PAGE); return nullptr; diff --git a/src/esp32/config.h b/src/esp32/config.h index b958e1c..eec66e8 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.10.0" +#define FIRMWARE_VERSION "2.11.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/display/page_store.cpp b/src/esp32/display/page_store.cpp index 95c0089..0ebbce8 100644 --- a/src/esp32/display/page_store.cpp +++ b/src/esp32/display/page_store.cpp @@ -301,14 +301,31 @@ static String path(const char *name) return String(DIR) + "/" + name + EXTENSION; } +static bool storedPage(const char *name) +{ + return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name)); +} + +void pageStoreBegin() +{ + DynamicPage *page = new DynamicPage; + for (int i = 0; i < builtinPageCount(); i++) + if (bitmapStorageAvailable() && !storedPage(builtinPageName(i)) && builtinPage(builtinPageName(i), *page)) + { + const char *error = savePage(builtinPageName(i), *page); + Serial.printf("Page \"%s\" stored from the original: %s\n", builtinPageName(i), error ? error : "ok"); + } + delete page; +} + std::vector listPages() { std::vector list; for (int i = 0; i < builtinPageCount(); i++) - list.push_back({builtinPageName(i), true}); + if (!storedPage(builtinPageName(i))) + list.push_back({builtinPageName(i), true, false}); for (const String &name : storedNames(DIR, EXTENSION)) - if (!builtinPageExists(name.c_str())) - list.push_back({name, false}); + list.push_back({name, false, builtinPageName(name.c_str()) != nullptr}); return list; } @@ -319,28 +336,21 @@ uint32_t pageListRevision() bool builtinPageExists(const char *name) { - for (int i = 0; i < builtinPageCount(); i++) - if (sameName(builtinPageName(i), name)) - return true; - return false; + return builtinPageName(name) != nullptr && !storedPage(name); } bool pageExists(const char *name) { - if (builtinPageExists(name)) - return true; - return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name)); + return builtinPageName(name) != nullptr || storedPage(name); } bool loadPage(const char *name, DynamicPage &page) { - if (builtinPage(name, page)) - return true; - if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name))) - return false; + if (!storedPage(name)) + return builtinPage(name, page); File file = LittleFS.open(path(name), "r"); if (!file) - return false; + return builtinPage(name, page); bool ok = false; size_t size = file.size(); if (size == sizeof(StoredPage)) @@ -401,15 +411,13 @@ bool loadPage(const char *name, DynamicPage &page) delete stored; } file.close(); - return ok; + return ok || builtinPage(name, page); } const char *savePage(const char *name, const DynamicPage &page) { if (!validBitmapName(name)) return "Invalid name: use 1-24 letters, digits, spaces, - or _"; - if (builtinPageExists(name)) - return "A built-in page has this name"; const char *error = validatePage(page); if (error) return error; @@ -435,7 +443,7 @@ const char *deletePage(const char *name) { if (builtinPageExists(name)) return "Built-in pages can't be deleted"; - if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name))) + if (!storedPage(name)) return "Page not found"; if (!LittleFS.remove(path(name))) return "Could not delete the file"; diff --git a/src/esp32/display/page_store.h b/src/esp32/display/page_store.h index 2b112cf..7208516 100644 --- a/src/esp32/display/page_store.h +++ b/src/esp32/display/page_store.h @@ -12,16 +12,23 @@ struct PageInfo { String name; - bool builtin; + bool builtin; // the original from logic/builtin_pages (no stored page of that name) + bool original; // a stored page that replaces a built-in one: deleting it brings that back }; const int MAX_PAGES = 50; -// Built-in pages first, then the stored ones +// At boot: stores a copy of each built-in page that is not stored (the Clock page), so it +// can be edited like the others. The built-in one stays the fallback: deleting the stored +// copy resets it (it is stored again at the next boot). +void pageStoreBegin(); + +// Built-in pages without a stored copy first, then the stored ones std::vector listPages(); // Changes each time a page is saved or deleted uint32_t pageListRevision(); bool pageExists(const char *name); +// A built-in page that no stored page replaces bool builtinPageExists(const char *name); bool loadPage(const char *name, DynamicPage &page); diff --git a/src/esp32/logic/builtin_pages.cpp b/src/esp32/logic/builtin_pages.cpp index dac7811..5bc0449 100644 --- a/src/esp32/logic/builtin_pages.cpp +++ b/src/esp32/logic/builtin_pages.cpp @@ -16,6 +16,14 @@ const char *builtinPageName(int index) return index >= 0 && index < builtinPageCount() ? NAMES[index] : nullptr; } +const char *builtinPageName(const char *name) +{ + for (const char *n : NAMES) + if (name && sameName(n, name)) + return n; + return nullptr; +} + static void clockPage(DynamicPage &page) { page = DynamicPage(); diff --git a/src/esp32/logic/builtin_pages.h b/src/esp32/logic/builtin_pages.h index 8aa84d7..1c194c4 100644 --- a/src/esp32/logic/builtin_pages.h +++ b/src/esp32/logic/builtin_pages.h @@ -2,8 +2,8 @@ #include "logic/page.h" -// The built-in dynamic pages. They can't be changed or deleted; the web UI saves an edited -// copy under a new name. +// The original dynamic pages. display/page_store stores a copy of each at boot that can be +// edited; the original is the fallback (deleting the copy resets the page). // // Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of // the old firmware; also the default page) @@ -12,5 +12,7 @@ int builtinPageCount(); const char *builtinPageName(int index); +// The built-in name as it is written, nullptr when it is no built-in page +const char *builtinPageName(const char *name); // False when there is no built-in page with this name (compared without case) bool builtinPage(const char *name, DynamicPage &page); diff --git a/src/esp32/logic/page_schedule.h b/src/esp32/logic/page_schedule.h index 5660e9b..8edf810 100644 --- a/src/esp32/logic/page_schedule.h +++ b/src/esp32/logic/page_schedule.h @@ -64,6 +64,7 @@ struct TriggerRule 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) + bool over_manual = true; // also over a page picked by hand (false: it waits until the pick ends) }; struct PageSchedule diff --git a/src/esp32/logic/page_selector.cpp b/src/esp32/logic/page_selector.cpp index 299672a..8050788 100644 --- a/src/esp32/logic/page_selector.cpp +++ b/src/esp32/logic/page_selector.cpp @@ -106,7 +106,7 @@ PageChoice PageSelector::update(int minute, uint32_t now) _rule_timer[i] = false; bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) || (r.mode == TriggerMode::ForSeconds && _rule_timer[i])) && - ruleInBlock(r, _block); + ruleInBlock(r, _block) && (r.over_manual || !_picked); if (takes_over) { choice.page = r.page; @@ -138,7 +138,8 @@ 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] && ruleInBlock(r, _block)) + if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block) && + (r.over_manual || !_picked)) 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 287d8a4..b0232fb 100644 --- a/src/esp32/logic/page_selector.h +++ b/src/esp32/logic/page_selector.h @@ -6,7 +6,8 @@ // Decides which page or bitmap is shown, from (highest priority first): // 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be -// limited to some schedule blocks (TriggerRule::blocks) +// limited to some schedule blocks (TriggerRule::blocks), and can leave a manual pick +// alone (TriggerRule::over_manual false: also its AddToRotation page) // 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/main.cpp b/src/esp32/main.cpp index 37c5d0b..c37f745 100644 --- a/src/esp32/main.cpp +++ b/src/esp32/main.cpp @@ -7,6 +7,7 @@ #include "app.h" #include "config.h" #include "display/bitmap_store.h" +#include "display/page_store.h" #include "logic/status_led.h" #include "nano_link/nano_flash_bridge.h" #include "net/ha_api.h" @@ -33,6 +34,7 @@ void setup() BootMode boot_mode = firmwareGuardBegin(); bitmapStoreBegin(); + pageStoreBegin(); // the Clock page as a page that can be edited appLoadSettings(); if (boot_mode == BootMode::Normal) diff --git a/src/esp32/net/ha_api.cpp b/src/esp32/net/ha_api.cpp index a93fa0e..c3cfa47 100644 --- a/src/esp32/net/ha_api.cpp +++ b/src/esp32/net/ha_api.cpp @@ -99,7 +99,7 @@ static const char *fetch(const Request &r, char *value, size_t value_size) JsonDocument filter; if (r.attribute[0]) - filter["attributes"][r.attribute] = true; + filter["attributes"][(const char *)r.attribute] = true; // a char array would be taken as a literal of 32 characters else filter["state"] = true; JsonDocument doc; @@ -110,7 +110,7 @@ static const char *fetch(const Request &r, char *value, size_t value_size) JsonVariantConst v = doc["state"]; if (r.attribute[0]) - v = doc["attributes"][r.attribute]; + v = doc["attributes"][(const char *)r.attribute]; if (v.isNull()) return "Unknown attribute"; String text; diff --git a/src/esp32/net/home_assistant.cpp b/src/esp32/net/home_assistant.cpp index da0998f..45938e8 100644 --- a/src/esp32/net/home_assistant.cpp +++ b/src/esp32/net/home_assistant.cpp @@ -97,7 +97,7 @@ static std::vector pageNames() { std::vector names, stored; for (const PageInfo &p : listPages()) - (p.builtin ? names : stored).push_back(p.name); + (p.builtin || p.original ? names : stored).push_back(p.name); std::sort(stored.begin(), stored.end(), lessName); names.insert(names.end(), stored.begin(), stored.end()); std::vector bitmaps = bitmapNames(); diff --git a/src/esp32/system/schedule_store.cpp b/src/esp32/system/schedule_store.cpp index 28bcf5f..33ec6b3 100644 --- a/src/esp32/system/schedule_store.cpp +++ b/src/esp32/system/schedule_store.cpp @@ -6,7 +6,10 @@ static const char *NAMESPACE = "flipdot"; static const char *KEY = "schedule"; // Increase when PageSchedule changes; older records are ignored unless converted below -static const uint16_t VERSION = 2; +static const uint16_t VERSION = 3; +// Version 2: the same layout, but TriggerRule::over_manual was padding (set to true) +static const uint16_t VERSION_WITHOUT_OVER_MANUAL = 2; +static_assert(sizeof(TriggerRule) == 164, "version 2 records are only read when TriggerRule kept its size"); struct StoredSchedule { @@ -52,7 +55,7 @@ static void convert(const Schedule &old, PageSchedule &s) s.enabled = old.enabled; s.block_count = old.block_count; memcpy(s.blocks, old.blocks, sizeof(s.blocks)); - s.rule_count = old.rule_count; + s.rule_count = old.rule_count; // over_manual: the default (true), like before for (int i = 0; i < TRIGGER_RULES; i++) { const Rule &o = old.rules[i]; @@ -81,9 +84,14 @@ void loadSchedule(PageSchedule &schedule) { StoredSchedule *stored = new StoredSchedule; if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) && - stored->version == VERSION && stored->size == sizeof(PageSchedule) && - validateSchedule(stored->schedule) == nullptr) + (stored->version == VERSION || stored->version == VERSION_WITHOUT_OVER_MANUAL) && + stored->size == sizeof(PageSchedule) && validateSchedule(stored->schedule) == nullptr) + { + if (stored->version == VERSION_WITHOUT_OVER_MANUAL) + for (TriggerRule &r : stored->schedule.rules) + r.over_manual = true; schedule = stored->schedule; + } delete stored; } else if (length == sizeof(v1::Stored)) diff --git a/src/esp32/web/schedule_json.cpp b/src/esp32/web/schedule_json.cpp index 076581a..005a379 100644 --- a/src/esp32/web/schedule_json.cpp +++ b/src/esp32/web/schedule_json.cpp @@ -59,6 +59,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out) o["mode"] = MODES[(int)r.mode]; o["seconds"] = r.seconds; o["interval"] = r.interval; + o["over_manual"] = r.over_manual; JsonArray in_blocks = o["blocks"].to(); for (int b = 0; b < s.block_count; b++) if ((r.blocks >> b) & 1) @@ -111,6 +112,7 @@ 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); + r.over_manual = o["over_manual"] | true; // 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 714316a..783e5cf 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -101,7 +101,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, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).

+

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 (when ticked) over 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

@@ -274,7 +274,7 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name))); const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'}); r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false))); - if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)} + if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),pg.original?btn('Reset',()=>resetPage(pg.name),'d'):btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)} for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap'; const u=used.has(bm.name.toLowerCase()); $(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name))); @@ -303,6 +303,7 @@ async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()} async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()} async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()} +async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();loadLists()} async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()} async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()} function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)} @@ -339,6 +340,8 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab 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, '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)'})), 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)'})):''))}); @@ -346,7 +349,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});schedDirty=true;schedBuild()} + sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true});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/src/esp32/web/web_ui.cpp b/src/esp32/web/web_ui.cpp index 701b46b..7c8ae8f 100644 --- a/src/esp32/web/web_ui.cpp +++ b/src/esp32/web/web_ui.cpp @@ -396,6 +396,8 @@ static void handlePages() JsonDocument doc; doc["name"] = info.name; doc["builtin"] = info.builtin; + if (info.original) + doc["original"] = true; // Delete resets it doc["data"] = hex; pageToJson(*page, doc["page"].to()); String item; diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 9c96c4d..783ef6a 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -1841,6 +1841,37 @@ void test_selector_take_over_rules() TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once } +void test_selector_rule_can_leave_a_manual_pick_alone() +{ + 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].over_manual = false; + s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10); + s.rules[1].over_manual = false; + PageSelector sel; + sel.setSchedule(&s); + sel.update(8 * 60, 0); + sel.setRuleState(0, true, 0); + TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 0).page); // no pick: it takes over + + sel.pick("Clock"); + PageChoice c = sel.update(8 * 60, 1000); + TEST_ASSERT_EQUAL_STRING("Clock", c.page); // waits while the pick lasts + TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen); + sel.setRuleState(1, true, 1000); + for (uint32_t t = 1000; t < 120000; t += 5000) + TEST_ASSERT_EQUAL_STRING("Clock", sel.update(8 * 60, t).page); // not in the rotation either + + sel.backToSchedule(); + TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 121000).page); + + // The next block ends a pick too + sel.pick("Clock"); + TEST_ASSERT_EQUAL_STRING("Clock", sel.update(16 * 60, 122000).page); + TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 123000).page); +} + void test_selector_rules_only_in_their_blocks() { PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie @@ -2415,6 +2446,7 @@ int main() 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_rule_can_leave_a_manual_pick_alone); RUN_TEST(test_selector_add_to_rotation); RUN_TEST(test_ha_auto_unit_and_absolute); RUN_TEST(test_ha_duration);