diff --git a/CLAUDE.md b/CLAUDE.md index 36e4ee0..2e88bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,13 @@ See README.md for the overview. changed rectangle (`lib/BROSE9323/frame_commands.h`, tested with the Nano parser in `test_nano`). Don't send frames pixel by pixel (`s` commands are 7 characters per dot). Character 127 is the degree sign in page texts and fonts (UTF-8 ° in JSON). +- What is shown is decided by `logic/page_selector` (rules > manual pick until the next + block > rotating schedule > chosen page) in `appPageLoop()`; `settings.page` is the chosen + page (the HA "Mode" dropdown). Schedule and rules are stored in NVS + (`system/schedule_store.cpp`, bump `VERSION` when `PageSchedule` changes). Pages used by + the schedule or rules can't be deleted. +- The ESP32 flash slot is about 90% full (web page and fonts are in flash). Check the size + after adding features; the web page could be served gzipped if it gets tight. - Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format. `logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it. - Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per diff --git a/README.md b/README.md index a6c374a..0563abf 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ 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, multiplier, decimals), 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. | -| **Bitmaps** | All bitmaps with a preview: 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. | +| **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. | +| **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. | | **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. | | **Home Assistant** | On/off, broker, port, user, password (changing these restarts the ESP32; an empty broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic pages (REST API, plain http; empty URL: `http://:8123`). | @@ -312,6 +314,8 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o - `FlipDot Display IP` sensor: the ESP32's IP address. - `FlipDot Display Stopwatch` switch and `FlipDot Display Stopwatch reset` button: the stopwatch of the dynamic pages. + - `FlipDot Display Shown page` sensor: what the display shows now (schedule, rule or + chosen page). `FlipDot Display Back to schedule` button: ends a page chosen by hand. 4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer. On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen to fix dots that did not flip. diff --git a/src/esp32/README.md b/src/esp32/README.md index b76b626..57d8390 100644 --- a/src/esp32/README.md +++ b/src/esp32/README.md @@ -25,6 +25,9 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be | `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing | | `logic/builtin_fonts.*` | Small, Normal, Large, Full height; the `.cpp` is generated by `tools/make_builtin_fonts.py` | | `logic/builtin_pages.*` | The built-in Clock page (the clock of the old firmware) | +| `logic/page_schedule.*` | Schedule blocks and trigger rules: the structs, checks, rule conditions | +| `logic/page_selector.*` | Which page is shown now: rules, a manual pick, the rotating schedule (`PageSelector`) | +| `logic/stopwatch.h` | The stopwatch of the dynamic pages | | `logic/power_timer.*` | Countdown to switching the display off | | `logic/status_led.h` | Blink pattern of the built-in LED | | `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode | @@ -33,6 +36,7 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be | `system/boot_target.*` | Switching between the clock firmware and the recovery app | | `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count | | `system/settings_store.*` | The web UI settings in NVS | +| `system/schedule_store.*` | The page schedule and rules in NVS | | **`display/`** | **What is drawn** | | `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) | | `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS | @@ -52,6 +56,7 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be | **`web/`** | **Web UI** (see the [main README](../../README.md#web-ui)) | | `web/web_ui.*` | Web server and JSON API (the endpoints are listed in `web_ui.h`) | | `web/page_json.*` | Dynamic pages as JSON | +| `web/schedule_json.*` | The page schedule and rules as JSON | | `web/web_page.h` | The page: HTML and JavaScript; the browser converts images and draws the previews | `DisplayController` only talks to two small interfaces, `DisplayPower` (implemented by diff --git a/src/esp32/app.cpp b/src/esp32/app.cpp index bfdc3f3..f7732b2 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -9,9 +9,17 @@ #include "display/font_store.h" #include "display/page_resources.h" #include "display/page_store.h" +#include "logic/page_selector.h" #include "net/ha_api.h" +#include "system/schedule_store.h" #include "system/settings_store.h" +static void copyName(char *out, const char *name) +{ + strncpy(out, name, PAGE_NAME_SIZE - 1); + out[PAGE_NAME_SIZE - 1] = '\0'; +} + NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN); BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH); FlipdotFrames frames(display, pageResources); @@ -23,6 +31,12 @@ static bool restart_pending = false; static uint32_t restart_at = 0; // Not on the stack: about 1.7 KB static DynamicPage loaded_page; +// What is shown when (about 2.7 KB), and which page that is now +static PageSchedule page_schedule; +static PageSelector selector; +static char shown[PAGE_NAME_SIZE] = DEFAULT_PAGE; +static PageChoice shown_choice; +static bool time_known = false; static void showDynamicPage() { @@ -31,23 +45,26 @@ static void showDynamicPage() controller.setPage(Page::Dynamic); } -// Load the page into the frames: a dynamic page or a bitmap; the Clock page when it is gone -static void applyPage() +// Load the shown page into the frames: a dynamic page or a bitmap; the Clock page when it is +// gone. instant: the next frame without the refresh effect. +static void applyPage(bool instant = false) { - if (loadPage(settings.page, loaded_page)) + if (instant) + frames.showNextInstant(); + if (loadPage(shown, loaded_page)) { showDynamicPage(); return; } uint8_t bitmap[BITMAP_BYTES]; - if (loadBitmap(settings.page, bitmap)) + if (loadBitmap(shown, bitmap)) { frames.setPageBitmap(bitmap); haApiWatch(nullptr); controller.setPage(Page::Bitmap); return; } - Serial.printf("Page \"%s\" not found, showing the clock\n", settings.page); + Serial.printf("Page \"%s\" not found, showing the clock\n", shown); builtinPage(DEFAULT_PAGE, loaded_page); showDynamicPage(); } @@ -92,6 +109,11 @@ const Settings &appSettings() void appLoadSettings() { loadSettings(settings); + loadSchedule(page_schedule); + selector.setSchedule(&page_schedule); + selector.setChosen(settings.page); + haApiWatchRules(&page_schedule); + copyName(shown, settings.page); // until the time is known apply(); } @@ -118,28 +140,119 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed) a.effect_small_only != b.effect_small_only; settings = new_settings; saveSettings(settings); + selector.setChosen(settings.page); apply(); if (drive_changed) status.sendDrive(); return nullptr; } -static void copyName(char *out, const char *name) + +// The chosen page, without overriding the schedule +static void setChosen(const char *name) { - strncpy(out, name, PAGE_NAME_SIZE - 1); - out[PAGE_NAME_SIZE - 1] = '\0'; + copyName(settings.page, name); + saveSettings(settings); + selector.setChosen(name); } const char *appSelectPage(const char *name) { if (!pageExists(name) && !bitmapExists(name)) return "Page not found"; - copyName(settings.page, name); - saveSettings(settings); - applyPage(); + setChosen(name); + selector.pick(name); + if (!time_known) + { + copyName(shown, name); + applyPage(); + } return nullptr; } +const char *appSaveSchedule(const PageSchedule &schedule) +{ + const char *error = validateSchedule(schedule); + if (error) + return error; + for (int i = 0; i < schedule.block_count; i++) + for (int j = 0; j < schedule.blocks[i].item_count; j++) + { + const char *page = schedule.blocks[i].items[j].page; + if (!pageExists(page) && !bitmapExists(page)) + return "A page of the schedule was not found"; + } + for (int i = 0; i < schedule.rule_count; i++) + if (!pageExists(schedule.rules[i].page) && !bitmapExists(schedule.rules[i].page)) + return "The page of a rule was not found"; + page_schedule = schedule; + saveSchedule(page_schedule); + selector.setSchedule(&page_schedule); + haApiWatchRules(&page_schedule); + return nullptr; +} + +const PageSchedule &appSchedule() +{ + return page_schedule; +} + +void appBackToSchedule() +{ + selector.backToSchedule(); +} + +const char *appShownPage() +{ + return shown; +} + +void appShowStatus(char *out, size_t size) +{ + static const char *const MODES[] = {"while true", "in rotation", "for a while"}; + switch (shown_choice.reason) + { + case ShowReason::Trigger: + case ShowReason::Rotation: + if (shown_choice.rule >= 0 && shown_choice.rule < page_schedule.rule_count) + { + const TriggerRule &r = page_schedule.rules[shown_choice.rule]; + snprintf(out, size, "rule %d (%s, %s)", shown_choice.rule + 1, r.entity, MODES[(int)r.mode]); + return; + } + break; + case ShowReason::Schedule: + snprintf(out, size, "schedule"); + return; + default: + break; + } + int until = selector.pickedUntil(); + if (until >= 0) + snprintf(out, size, "chosen, until %02d:%02d", until / 60, until % 60); + else + snprintf(out, size, selector.scheduleActive() ? "chosen" : "chosen (no schedule)"); +} + +// Which page now: the rules (from Home Assistant), the schedule, the chosen page +void appPageLoop(bool time_valid, const LocalTime &time) +{ + uint32_t now = millis(); + for (int i = 0; i < page_schedule.rule_count; i++) + { + const TriggerRule &r = page_schedule.rules[i]; + selector.setRuleState(i, ruleMatches(r, haApiValue(r.entity, r.attribute)), now); + } + time_known = time_valid; + if (!time_valid) + return; + shown_choice = selector.update(time.hour * 60 + time.minute, now); + if (!shown_choice.changed && sameName(shown, shown_choice.page)) + return; + copyName(shown, shown_choice.page); + applyPage(shown_choice.instant); +} + const char *appSelectOffPage(const char *name) { if (name[0] && !bitmapExists(name)) @@ -172,8 +285,12 @@ static const char *usedBy(const String &page) return message; } +static const char *USED_BY_SCHEDULE = "Used by the schedule or a rule: change that first"; + const char *appDeleteBitmap(const char *name) { + if (scheduleUses(page_schedule, name)) + return USED_BY_SCHEDULE; String user = pageUsingBitmap(name); if (user.length()) return usedBy(user); @@ -181,7 +298,7 @@ const char *appDeleteBitmap(const char *name) if (error) return error; if (sameName(settings.page, name)) - appSelectPage(DEFAULT_PAGE); + setChosen(DEFAULT_PAGE); if (sameName(settings.off_page, name)) appSelectOffPage(OFF_PAGE_BLANK); return nullptr; @@ -200,18 +317,20 @@ const char *appSavePage(const char *name, const DynamicPage &page) const char *error = savePage(name, page); if (error) return error; - if (sameName(settings.page, name)) + if (sameName(shown, name)) reloadPage(); return nullptr; } const char *appDeletePage(const char *name) { + if (scheduleUses(page_schedule, name)) + return USED_BY_SCHEDULE; const char *error = deletePage(name); if (error) return error; if (sameName(settings.page, name)) - appSelectPage(DEFAULT_PAGE); + setChosen(DEFAULT_PAGE); return nullptr; } diff --git a/src/esp32/app.h b/src/esp32/app.h index e81f0de..7a73501 100644 --- a/src/esp32/app.h +++ b/src/esp32/app.h @@ -9,6 +9,7 @@ #include "logic/display_controller.h" #include "logic/local_time.h" #include "logic/page.h" +#include "logic/page_schedule.h" #include "logic/settings.h" #include "nano_link/nano_link.h" @@ -25,8 +26,21 @@ void appLoadSettings(); // restart_needed when the change only works after a restart (Home Assistant MQTT). const char *appSaveSettings(const Settings &settings, bool &restart_needed); -// The page shown during the day: a dynamic page or a bitmap (stored in the settings) +// The chosen page (settings): a dynamic page or a bitmap. With a schedule it is shown until +// the next block starts (a manual pick), without one all the time. const char *appSelectPage(const char *name); + +// The schedule and trigger rules (validated, stored, used right away) +const char *appSaveSchedule(const PageSchedule &schedule); +const PageSchedule &appSchedule(); +// Ends a manual pick: the schedule decides again +void appBackToSchedule(); +// The page or bitmap shown now, and why (e.g. "schedule", "chosen, until 17:00") +const char *appShownPage(); +void appShowStatus(char *out, size_t size); +// Decides the shown page (rules, schedule, chosen page); call every loop() before the +// display controller +void appPageLoop(bool time_valid, const LocalTime &time); // Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name const char *appSelectOffPage(const char *name); diff --git a/src/esp32/config.h b/src/esp32/config.h index 45a2b8d..36ecfd1 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -49,6 +49,8 @@ const uint32_t POWER_OFF_DELAY = 30000; // timeout (a read blocks loop() for at most about twice this) const uint32_t HA_POLL_INTERVAL = 60000; const uint32_t HA_HTTP_TIMEOUT = 1500; +// After a failed read (Home Assistant down) an entity is read again after this at the soonest +const uint32_t HA_ERROR_INTERVAL = 60000; // Moving dynamic pages (scrolling, seconds): the next frame waits until the Nano received // this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000; @@ -103,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.5.0" +#define FIRMWARE_VERSION "2.6.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/frames.cpp b/src/esp32/display/frames.cpp index d9ce125..df5932e 100644 --- a/src/esp32/display/frames.cpp +++ b/src/esp32/display/frames.cpp @@ -18,7 +18,8 @@ uint32_t FlipdotFrames::dynamicPage(const LocalTime &time, uint32_t now, bool re bool moving = wait != 0; // The Nano has no acknowledgement: the next frame waits until it has received this one // and flipped its dots, or the next frame overflows its serial buffer - uint32_t busy = _show(bitmap, !redraw, moving); + uint32_t busy = _show(bitmap, !redraw, moving || _instant_next); + _instant_next = false; return moving && wait < busy ? busy : wait; } @@ -50,7 +51,8 @@ void FlipdotFrames::noWifi(int hour, int minute) void FlipdotFrames::page() { - _drawFullScreen(_page); + _show(_page, false, _instant_next); + _instant_next = false; } void FlipdotFrames::live() diff --git a/src/esp32/display/frames.h b/src/esp32/display/frames.h index 14620aa..1ab2d46 100644 --- a/src/esp32/display/frames.h +++ b/src/esp32/display/frames.h @@ -26,6 +26,9 @@ public: void off() override; void setDynamicPage(const DynamicPage &page); + // The next page frame is shown without the refresh effect (a rule's page, a schedule + // block that switches instantly) + void showNextInstant() { _instant_next = true; } // How the Nano flips the dots: for the time a frame takes void setDrive(uint16_t flip_time, const DriveSettings &drive); void setPageBitmap(const uint8_t *bitmap); @@ -37,7 +40,8 @@ private: BROSE9323 &_display; PageResources &_resources; DynamicPage _dynamic; - uint32_t _dot_us = 1000; // per changed dot: pulses x (flip time + gap) + uint32_t _dot_us = 1000; + bool _instant_next = false; // per changed dot: pulses x (flip time + gap) uint8_t _page[BITMAP_BYTES] = {}; uint8_t _live[BITMAP_BYTES] = {}; uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank diff --git a/src/esp32/logic/page_schedule.cpp b/src/esp32/logic/page_schedule.cpp new file mode 100644 index 0000000..d52b17d --- /dev/null +++ b/src/esp32/logic/page_schedule.cpp @@ -0,0 +1,112 @@ +#include "logic/page_schedule.h" + +#include +#include +#include + +static bool terminated(const char *text, size_t size) +{ + return memchr(text, '\0', size) != nullptr; +} + +static bool validAttribute(const char *attribute) +{ + for (const char *c = attribute; *c; c++) + if (!isalnum((unsigned char)*c) && *c != '_') + return false; + return true; +} + +const char *validateSchedule(const PageSchedule &s) +{ + if (s.block_count > SCHEDULE_BLOCKS) + return "Too many schedule blocks (8 at most)"; + if (s.rule_count > TRIGGER_RULES) + return "Too many rules (8 at most)"; + + for (int i = 0; i < s.block_count; i++) + { + const ScheduleBlock &b = s.blocks[i]; + if (b.start >= 24 * 60) + return "Blocks must start before 24:00"; + if (i > 0 && b.start <= s.blocks[i - 1].start) + return "Blocks must be in order, each at a different time"; + if (b.item_count == 0 || b.item_count > BLOCK_ITEMS) + return "Each block needs 1 to 6 pages"; + for (int j = 0; j < b.item_count; j++) + { + const ScheduleItem &item = b.items[j]; + if (!terminated(item.page, sizeof(item.page)) || !validBitmapName(item.page)) + return "Choose a page for each item of a block"; + if (item.seconds < ITEM_SECONDS_MIN || item.seconds > ITEM_SECONDS_MAX) + return "Pages are shown 5 to 3600 seconds"; + } + } + + for (int i = 0; i < s.rule_count; i++) + { + const TriggerRule &r = s.rules[i]; + if (!terminated(r.entity, sizeof(r.entity)) || !validEntityId(r.entity)) + return "Rules need an entity id like sensor.printer_state"; + if (!terminated(r.attribute, sizeof(r.attribute)) || !validAttribute(r.attribute)) + return "Invalid attribute"; + if (r.compare >= Compare::Count || r.mode >= TriggerMode::Count) + return "Unknown rule setting"; + if (!terminated(r.value, sizeof(r.value))) + return "Rule value too long (32 characters)"; + if (!terminated(r.page, sizeof(r.page)) || !validBitmapName(r.page)) + return "Choose a page for each rule"; + if (r.mode != TriggerMode::WhileTrue && (r.seconds < 1 || r.seconds > ITEM_SECONDS_MAX)) + 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"; + } + return nullptr; +} + +static bool sameText(const char *a, const char *b) +{ + for (; *a && *b; a++, b++) + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) + return false; + return *a == *b; +} + +static bool number(const char *text, double &value) +{ + char *end; + value = strtod(text, &end); + return end != text && *end == '\0'; +} + +bool ruleMatches(const TriggerRule &rule, const char *value) +{ + if (value == nullptr) + return false; + double a, b; + switch (rule.compare) + { + case Compare::Equal: + return sameText(value, rule.value); + case Compare::NotEqual: + return !sameText(value, rule.value); + case Compare::Above: + return number(value, a) && number(rule.value, b) && a > b; + case Compare::Below: + return number(value, a) && number(rule.value, b) && a < b; + default: + return false; + } +} + +bool scheduleUses(const PageSchedule &s, const char *name) +{ + for (int i = 0; i < s.block_count; i++) + for (int j = 0; j < s.blocks[i].item_count; j++) + if (sameName(s.blocks[i].items[j].page, name)) + return true; + for (int i = 0; i < s.rule_count; i++) + if (sameName(s.rules[i].page, name)) + return true; + return false; +} diff --git a/src/esp32/logic/page_schedule.h b/src/esp32/logic/page_schedule.h new file mode 100644 index 0000000..c46871f --- /dev/null +++ b/src/esp32/logic/page_schedule.h @@ -0,0 +1,85 @@ +#pragma once + +#include + +#include "logic/page.h" + +// What is shown when: schedule blocks (pages or bitmaps that rotate, from a time of day +// until the next block) and trigger rules (Home Assistant states that bring a page to the +// front). Edited in the web UI, stored in NVS (system/schedule_store). Which page is shown +// right now is decided by logic/page_selector. Pure logic, tested in test/test_esp32. +// +// The display schedule (logic/schedule.h) still decides when the display is on. + +const int SCHEDULE_BLOCKS = 8; +const int BLOCK_ITEMS = 6; +const int TRIGGER_RULES = 8; +const int RULE_VALUE_SIZE = 33; +const uint16_t ITEM_SECONDS_MIN = 5; +const uint16_t ITEM_SECONDS_MAX = 3600; +const uint16_t RULE_INTERVAL_MIN = 2; +const uint16_t RULE_INTERVAL_MAX = 600; + +// A page or bitmap, shown for `seconds` when its block rotates +struct ScheduleItem +{ + char page[PAGE_NAME_SIZE] = ""; + uint16_t seconds = 30; +}; + +// From `start` (minutes since midnight) until the next block +struct ScheduleBlock +{ + uint16_t start = 0; + bool effect = true; // switch to the next page with the refresh effect (false: instantly) + uint8_t item_count = 0; + ScheduleItem items[BLOCK_ITEMS]; +}; + +enum class Compare : uint8_t +{ + Equal, // text, without case + NotEqual, + Above, // numbers + Below, + Count, +}; + +enum class TriggerMode : uint8_t +{ + WhileTrue, // the page takes over while the condition is true + AddToRotation, // the page joins the rotation (for `seconds`) while it is true + ForSeconds, // the page takes over for `seconds` after the condition becomes true + Count, +}; + +struct TriggerRule +{ + char entity[ELEMENT_ENTITY_SIZE] = ""; + char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // empty: the state + Compare compare = Compare::Equal; + char value[RULE_VALUE_SIZE] = ""; + char page[PAGE_NAME_SIZE] = ""; + 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) +}; + +struct PageSchedule +{ + bool enabled = true; // false: the blocks are kept but not used (the rules still work) + uint8_t block_count = 0; + ScheduleBlock blocks[SCHEDULE_BLOCKS]; + uint8_t rule_count = 0; + TriggerRule rules[TRIGGER_RULES]; +}; + +// Returns nullptr when it is fine, otherwise a message for the user. Does not check that +// the pages exist. +const char *validateSchedule(const PageSchedule &schedule); + +// Whether a rule's condition is true for this value (nullptr: not known, never true) +bool ruleMatches(const TriggerRule &rule, const char *value); + +// Whether the schedule or a rule shows this page or bitmap (compared without case) +bool scheduleUses(const PageSchedule &schedule, const char *name); diff --git a/src/esp32/logic/page_selector.cpp b/src/esp32/logic/page_selector.cpp new file mode 100644 index 0000000..f10493e --- /dev/null +++ b/src/esp32/logic/page_selector.cpp @@ -0,0 +1,166 @@ +#include "logic/page_selector.h" + +#include + +static void copyName(char *out, const char *name) +{ + strncpy(out, name ? name : "", PAGE_NAME_SIZE - 1); + out[PAGE_NAME_SIZE - 1] = '\0'; +} + +void PageSelector::setSchedule(const PageSchedule *schedule) +{ + _schedule = schedule; + _picked = false; + _block = -1; + _last_minute = -1; + memset(_rule_active, 0, sizeof(_rule_active)); + memset(_rule_timer, 0, sizeof(_rule_timer)); +} + +void PageSelector::setChosen(const char *page) +{ + copyName(_chosen, page); +} + +void PageSelector::pick(const char *page) +{ + setChosen(page); + _picked = scheduleActive(); + _item = 0; +} + +void PageSelector::backToSchedule() +{ + _picked = false; +} + +bool PageSelector::scheduleActive() const +{ + return _schedule && _schedule->enabled && _schedule->block_count > 0; +} + +int PageSelector::_blockAt(int minute) const +{ + int block = _schedule->block_count - 1; // before the first start: the last block of the day before + for (int i = 0; i < _schedule->block_count; i++) + if (_schedule->blocks[i].start <= minute) + block = i; + return block; +} + +int PageSelector::pickedUntil() const +{ + if (!_picked || !scheduleActive() || _block < 0) + return -1; + return _schedule->blocks[(_block + 1) % _schedule->block_count].start; +} + +void PageSelector::setRuleState(int rule, bool active, uint32_t now) +{ + if (rule < 0 || rule >= TRIGGER_RULES) + return; + if (active && !_rule_active[rule]) + { + // Becomes true: a ForSeconds rule starts (again) + _rule_timer[rule] = true; + _rule_since[rule] = now; + } + _rule_active[rule] = active; +} + +bool PageSelector::ruleActive(int rule) const +{ + return rule >= 0 && rule < TRIGGER_RULES && _rule_active[rule]; +} + +PageChoice PageSelector::update(int minute, uint32_t now) +{ + PageChoice choice; + bool schedule = scheduleActive(); + bool effect = true; // switching with the refresh effect + + // A block starts: it ends a manual pick and starts its rotation from the first page. + // With one block that is its start time each day. + if (schedule && minute != _last_minute) + { + int block = _blockAt(minute); + if (block != _block || _schedule->blocks[block].start == minute) + { + _picked = false; + _item = 0; + _item_since = now; + } + _block = block; + } + if (!schedule) + _block = -1; + _last_minute = minute; + + // 1. Take-over rules + int rule_count = _schedule ? _schedule->rule_count : 0; + for (int i = 0; i < rule_count && choice.rule < 0; i++) + { + 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]); + if (takes_over) + { + choice.page = r.page; + choice.reason = ShowReason::Trigger; + choice.rule = i; + } + } + + // 2-4. The rotation: the block's pages (or the chosen page), then the AddToRotation pages + if (choice.rule < 0) + { + struct Item + { + const char *page; + uint32_t ms; + ShowReason reason; + int rule; + } items[BLOCK_ITEMS + TRIGGER_RULES]; + int count = 0; + if (schedule && !_picked) + { + 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}; + } + else + 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]) + items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i}; + } + + if (_item >= count) + { + _item = 0; + _item_since = now; + } + if (now - _item_since >= items[_item].ms) + { + _item = (_item + 1) % count; + _item_since = now; + } + choice.page = items[_item].page; + choice.reason = items[_item].reason; + choice.rule = items[_item].rule; + } + + choice.changed = !sameName(choice.page, _shown); + // Rules bring their page at once, and leave at once; the schedule as set per block + choice.instant = choice.changed && (choice.reason == ShowReason::Trigger || _shown_reason == ShowReason::Trigger || + !effect); + copyName(_shown, choice.page); + _shown_reason = choice.reason; + return choice; +} diff --git a/src/esp32/logic/page_selector.h b/src/esp32/logic/page_selector.h new file mode 100644 index 0000000..321b825 --- /dev/null +++ b/src/esp32/logic/page_selector.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +#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 +// 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 +// 4. without a schedule (none, or switched off): the chosen page, plus AddToRotation pages +// Pure logic, tested in test/test_esp32. Times are millis(). + +enum class ShowReason : uint8_t +{ + Chosen, // the chosen page (manual pick, or no schedule) + Schedule, // a schedule block + Rotation, // an AddToRotation rule + Trigger, // a take-over rule +}; + +struct PageChoice +{ + const char *page = ""; + ShowReason reason = ShowReason::Chosen; + int rule = -1; // the rule (Rotation, Trigger) + bool changed = false; // another page than at the last update() + bool instant = false; // when changed: switch without the refresh effect +}; + +// The chosen page also rotates with AddToRotation pages, this long +const uint16_t CHOSEN_PAGE_SECONDS = 30; + +class PageSelector +{ +public: + // The schedule must stay valid (the selector keeps the pointer). Starts it fresh: ends a + // manual pick, forgets the rule states. + void setSchedule(const PageSchedule *schedule); + // The chosen page (settings.page): shown without a schedule, and after pick() + void setChosen(const char *page); + // A manual pick: setChosen() and, when a schedule runs, show it until the next block + void pick(const char *page); + void backToSchedule(); + // A manual pick overrides the schedule now + bool picked() const { return _picked; } + // The schedule is used (switched on, with blocks) + bool scheduleActive() const; + // Start of the block that ends a manual pick (minutes since midnight), -1 when none + int pickedUntil() const; + + // 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; + + PageChoice update(int minute_of_day, uint32_t now); + +private: + const PageSchedule *_schedule = nullptr; + char _chosen[PAGE_NAME_SIZE] = ""; + bool _picked = false; + int _block = -1; + int _last_minute = -1; + int _item = 0; + uint32_t _item_since = 0; + bool _rule_active[TRIGGER_RULES] = {}; + bool _rule_timer[TRIGGER_RULES] = {}; // ForSeconds runs + uint32_t _rule_since[TRIGGER_RULES] = {}; + char _shown[PAGE_NAME_SIZE] = ""; + ShowReason _shown_reason = ShowReason::Chosen; + + int _blockAt(int minute_of_day) const; +}; diff --git a/src/esp32/main.cpp b/src/esp32/main.cpp index 3254886..c498dfd 100644 --- a/src/esp32/main.cpp +++ b/src/esp32/main.cpp @@ -90,6 +90,7 @@ void loop() LocalTime now; bool time_valid = appLocalTime(now); + appPageLoop(time_valid, now); controller.update(millis(), time_valid, now, wifiConnected()); bool led = appSettings().status_led && statusLedLevel(millis(), wifiConnected(), nano.isOn()); diff --git a/src/esp32/net/ha_api.cpp b/src/esp32/net/ha_api.cpp index 376a5f6..eab8c7d 100644 --- a/src/esp32/net/ha_api.cpp +++ b/src/esp32/net/ha_api.cpp @@ -14,15 +14,23 @@ struct Entry String attribute; String value; bool known; - bool watched; + bool page_watched; // used by the shown page: read every HA_POLL_INTERVAL + uint32_t rule_interval; // used by trigger rules: read this often (ms), 0 = not uint32_t next_poll; + + bool watched() const { return page_watched || rule_interval; } + uint32_t interval() const + { + uint32_t page = page_watched ? HA_POLL_INTERVAL : UINT32_MAX; + return rule_interval && rule_interval < page ? rule_interval : page; + } }; static String base_url; static String token; static std::vector entries; // Kept for the editor's previews, besides the watched ones -static const size_t MAX_ENTRIES = 16; +static const size_t MAX_ENTRIES = 24; void haApiSetSettings(const HomeAssistantSettings &settings) { @@ -52,19 +60,19 @@ static Entry &add(const char *entity, const char *attribute) return *e; if (entries.size() >= MAX_ENTRIES) for (auto it = entries.begin(); it != entries.end(); ++it) - if (!it->watched) + if (!it->watched()) { entries.erase(it); break; } - entries.push_back({entity, attribute, "", false, false, millis()}); + entries.push_back({entity, attribute, "", false, false, 0, millis()}); return entries.back(); } void haApiWatch(const DynamicPage *page) { for (Entry &e : entries) - e.watched = false; + e.page_watched = false; if (!page) return; for (int i = 0; i < page->element_count; i++) @@ -74,9 +82,27 @@ void haApiWatch(const DynamicPage *page) if (!uses_state || !validEntityId(element.entity)) continue; Entry &e = add(element.entity, element.attribute); - if (!e.watched) + if (!e.watched()) e.next_poll = millis(); - e.watched = true; + e.page_watched = true; + } +} + +void haApiWatchRules(const PageSchedule *schedule) +{ + for (Entry &e : entries) + e.rule_interval = 0; + if (!schedule) + return; + for (int i = 0; i < schedule->rule_count; i++) + { + const TriggerRule &rule = schedule->rules[i]; + Entry &e = add(rule.entity, rule.attribute); + if (!e.watched()) + e.next_poll = millis(); + uint32_t ms = rule.interval * 1000UL; + if (!e.rule_interval || ms < e.rule_interval) + e.rule_interval = ms; } } @@ -139,20 +165,23 @@ bool haApiLoop(bool wifi_connected) uint32_t now = millis(); for (Entry &e : entries) { - if (!e.watched || (int32_t)(now - e.next_poll) < 0) + if (!e.watched() || (int32_t)(now - e.next_poll) < 0) continue; - e.next_poll = now + HA_POLL_INTERVAL; + e.next_poll = now + e.interval(); String value; const char *error = fetch(e.entity, e.attribute, value); if (error) { - // Shown as "--" until it can be read again + // Shown as "--" until it can be read again. Read less often, so a Home + // Assistant that is down doesn't block loop() all the time. Serial.printf("Home Assistant %s: %s\n", e.entity.c_str(), error); - bool changed = e.known; + if (e.interval() < HA_ERROR_INTERVAL) + e.next_poll = now + HA_ERROR_INTERVAL; + bool changed = e.known && e.page_watched; e.known = false; return changed; } - bool changed = !e.known || e.value != value; + bool changed = (!e.known || e.value != value) && e.page_watched; e.value = value; e.known = true; return changed; // one entity per loop() diff --git a/src/esp32/net/ha_api.h b/src/esp32/net/ha_api.h index db89f68..5e0a8f7 100644 --- a/src/esp32/net/ha_api.h +++ b/src/esp32/net/ha_api.h @@ -3,12 +3,14 @@ #include #include "logic/page.h" +#include "logic/page_schedule.h" #include "logic/settings.h" // Values of Home Assistant entities for the dynamic pages, read over Home Assistant's REST // API (GET /api/states/ with a long-lived access token). Only the entities of -// the shown page are polled, every HA_POLL_INTERVAL, one per loop() so loop() never blocks -// longer than about 2 x HA_HTTP_TIMEOUT. Plain http only. +// the shown page (every HA_POLL_INTERVAL) and of the trigger rules (their own interval) are +// polled, one per loop() so loop() never blocks longer than about 2 x HA_HTTP_TIMEOUT. +// Plain http only. // URL and token; empty URL: http://:8123 void haApiSetSettings(const HomeAssistantSettings &settings); @@ -16,7 +18,11 @@ void haApiSetSettings(const HomeAssistantSettings &settings); // The page whose entities are polled (nullptr: none). They are read right away. void haApiWatch(const DynamicPage *page); -// Polls one entity that is due. Returns true when a watched value changed (redraw). +// The entities of the trigger rules, each read at its rule's interval (nullptr: none) +void haApiWatchRules(const PageSchedule *schedule); + +// Polls one entity that is due. Returns true when a value of the shown page changed (redraw). +// After an error an entity is read again after HA_ERROR_INTERVAL at the soonest. bool haApiLoop(bool wifi_connected); // The last value read for an entity (attribute empty: the state); nullptr when unknown diff --git a/src/esp32/net/home_assistant.cpp b/src/esp32/net/home_assistant.cpp index 1b3ff62..da0998f 100644 --- a/src/esp32/net/home_assistant.cpp +++ b/src/esp32/net/home_assistant.cpp @@ -30,6 +30,8 @@ static HASensor ipSensor("FlipDotDisplayIP"); static HAButton recoveryButton("FlipDotDisplayRecovery"); static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch"); static HAButton stopwatchReset("FlipDotDisplayStopwatchReset"); +static HASensor shownSensor("FlipDotDisplayShownPage"); +static HAButton backButton("FlipDotDisplayBackToSchedule"); // Removed entities: their retained discovery config is cleared, so Home Assistant // deletes them instead of showing them as unavailable @@ -61,6 +63,7 @@ struct TextSensor static TextSensor firmware = {firmwareSensor, "", false}; static TextSensor ip = {ipSensor, "", false}; +static TextSensor shownPage = {shownSensor, "", false}; static DisplayController *displayController = nullptr; static bool enabled = false; @@ -233,6 +236,12 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin recoveryButton.setIcon("mdi:lifebuoy"); recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); }); + shownSensor.setName("FlipDot Display Shown page"); + shownSensor.setIcon("mdi:monitor-eye"); + backButton.setName("FlipDot Display Back to schedule"); + backButton.setIcon("mdi:calendar-clock"); + backButton.onCommand([](HAButton *) { appBackToSchedule(); }); + firmwareSensor.setName("FlipDot Display Firmware"); firmwareSensor.setIcon("mdi:chip"); @@ -241,6 +250,7 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin mqtt.onConnected([]() { firmware.sent = false; + shownPage.sent = false; ip.sent = false; clearRemovedEntities(); }); @@ -271,6 +281,8 @@ void homeAssistantLoop() ip.set(WiFi.localIP().toString().c_str()); } ip.loop(); + shownPage.set(appShownPage()); + shownPage.loop(); firmware.loop(); mode.update(); offImage.update(); diff --git a/src/esp32/net/home_assistant.h b/src/esp32/net/home_assistant.h index cb9fddf..bf69592 100644 --- a/src/esp32/net/home_assistant.h +++ b/src/esp32/net/home_assistant.h @@ -14,6 +14,8 @@ // FlipDot Display Recovery button: restart into the recovery app (for 10 minutes) // FlipDot Display Stopwatch switch: the stopwatch of the dynamic pages runs // FlipDot Display Stopwatch reset button: back to 0 +// FlipDot Display Shown page sensor: the page or bitmap on the display now +// FlipDot Display Back to schedule button: ends a manual pick (the Mode dropdown) // // The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER // and MQTT_PASSWORD from secrets.h. Changes need a restart. diff --git a/src/esp32/system/schedule_store.cpp b/src/esp32/system/schedule_store.cpp new file mode 100644 index 0000000..95656b1 --- /dev/null +++ b/src/esp32/system/schedule_store.cpp @@ -0,0 +1,43 @@ +#include "system/schedule_store.h" + +#include +#include + +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; + +struct StoredSchedule +{ + uint16_t version; + uint16_t size; + PageSchedule schedule; +}; + +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) && + validateSchedule(stored->schedule) == nullptr) + schedule = stored->schedule; + delete stored; +} + +void saveSchedule(const PageSchedule &schedule) +{ + StoredSchedule *stored = new StoredSchedule; + stored->version = VERSION; + stored->size = sizeof(PageSchedule); + stored->schedule = schedule; + Preferences prefs; + prefs.begin(NAMESPACE, false); + prefs.putBytes(KEY, stored, sizeof(StoredSchedule)); + prefs.end(); + delete stored; +} diff --git a/src/esp32/system/schedule_store.h b/src/esp32/system/schedule_store.h new file mode 100644 index 0000000..de0614f --- /dev/null +++ b/src/esp32/system/schedule_store.h @@ -0,0 +1,8 @@ +#pragma once + +#include "logic/page_schedule.h" + +// The page schedule and trigger rules in NVS (namespace "flipdot", key "schedule"). A +// missing, outdated or invalid record gives an empty schedule. +void loadSchedule(PageSchedule &schedule); +void saveSchedule(const PageSchedule &schedule); diff --git a/src/esp32/web/schedule_json.cpp b/src/esp32/web/schedule_json.cpp new file mode 100644 index 0000000..cabd159 --- /dev/null +++ b/src/esp32/web/schedule_json.cpp @@ -0,0 +1,109 @@ +#include "web/schedule_json.h" + +#include +#include + +#include "logic/settings.h" + +static const char *const COMPARES[] = {"eq", "ne", "gt", "lt"}; +static const char *const MODES[] = {"while", "rotate", "for"}; + +template static int indexOf(const char *const (&names)[N], const char *name) +{ + for (size_t i = 0; i < N; i++) + if (name && strcmp(names[i], name) == 0) + return i; + return -1; +} + +// False when the text does not fit +static bool copyText(JsonVariantConst value, char *out, size_t size) +{ + const char *text = value | ""; + if (strlen(text) >= size) + return false; + strcpy(out, text); + return true; +} + +void scheduleToJson(const PageSchedule &s, JsonObject out) +{ + char time[6]; + out["enabled"] = s.enabled; + JsonArray blocks = out["blocks"].to(); + for (int i = 0; i < s.block_count; i++) + { + const ScheduleBlock &b = s.blocks[i]; + JsonObject o = blocks.add(); + formatTime(b.start, time); + o["start"] = String(time); + o["effect"] = b.effect; + JsonArray items = o["items"].to(); + for (int j = 0; j < b.item_count; j++) + { + JsonObject item = items.add(); + item["page"] = b.items[j].page; + item["seconds"] = b.items[j].seconds; + } + } + JsonArray rules = out["rules"].to(); + for (int i = 0; i < s.rule_count; i++) + { + const TriggerRule &r = s.rules[i]; + JsonObject o = rules.add(); + o["entity"] = r.entity; + o["attribute"] = r.attribute; + o["compare"] = COMPARES[(int)r.compare]; + o["value"] = r.value; + o["page"] = r.page; + o["mode"] = MODES[(int)r.mode]; + o["seconds"] = r.seconds; + o["interval"] = r.interval; + } +} + +const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s) +{ + s = PageSchedule(); + s.enabled = in["enabled"] | true; + JsonArrayConst blocks = in["blocks"]; + JsonArrayConst rules = in["rules"]; + if (blocks.size() > (size_t)SCHEDULE_BLOCKS) + return "Too many schedule blocks (8 at most)"; + if (rules.size() > (size_t)TRIGGER_RULES) + return "Too many rules (8 at most)"; + + for (JsonVariantConst o : blocks) + { + ScheduleBlock &b = s.blocks[s.block_count++]; + if (!parseTime(o["start"] | "", b.start)) + return "Block times must look like 07:00"; + b.effect = o["effect"] | true; + JsonArrayConst items = o["items"]; + if (items.size() > (size_t)BLOCK_ITEMS) + return "6 pages per block at most"; + for (JsonVariantConst i : items) + { + ScheduleItem &item = b.items[b.item_count++]; + if (!copyText(i["page"], item.page, sizeof(item.page))) + return "Page name too long"; + item.seconds = constrain(i["seconds"] | 30, 0, 65535); + } + } + + for (JsonVariantConst o : rules) + { + TriggerRule &r = s.rules[s.rule_count++]; + int compare = indexOf(COMPARES, o["compare"] | "eq"), mode = indexOf(MODES, o["mode"] | "while"); + if (compare < 0 || mode < 0) + return "Unknown rule setting"; + r.compare = (Compare)compare; + r.mode = (TriggerMode)mode; + if (!copyText(o["entity"], r.entity, sizeof(r.entity)) || !copyText(o["attribute"], r.attribute, sizeof(r.attribute)) || + !copyText(o["value"], r.value, sizeof(r.value)) || !copyText(o["page"], r.page, sizeof(r.page))) + return "Rule text too long"; + r.seconds = constrain(o["seconds"] | 30, 0, 65535); + r.interval = constrain(o["interval"] | 10, 0, 65535); + } + return validateSchedule(s); +} diff --git a/src/esp32/web/schedule_json.h b/src/esp32/web/schedule_json.h new file mode 100644 index 0000000..c34b03c --- /dev/null +++ b/src/esp32/web/schedule_json.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "logic/page_schedule.h" + +// The page schedule (logic/page_schedule.h) as JSON for the web UI: +// {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}]} + +void scheduleToJson(const PageSchedule &schedule, JsonObject out); +// Returns nullptr or a message for the user. Validates (validateSchedule()). +const char *scheduleFromJson(JsonVariantConst in, PageSchedule &schedule); diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 57b3601..ee8b87a 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -37,6 +37,7 @@ button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);bor button.d{color:var(--bad)} #editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move} #f_canvas{width:100%;background:#000;border-radius:8px} +details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px} .el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)} .form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center} .hint{color:var(--dim);font-size:12px}#msg{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px;display:none} @@ -49,11 +50,12 @@ button.d{color:var(--bad)}

What to show during the day

+
-
Stopwatch 0:00 -
-

The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.

+
+

The display schedule (Settings) decides when the display is on; the Schedule tab what it shows when. With a schedule, a page chosen here (or with the Home Assistant "Mode" dropdown) is shown until the next block starts.

Left on the display when it switches off

+

Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".

Dynamic pages

@@ -89,7 +91,24 @@ button.d{color:var(--bad)}

Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).

+

Now

+
+

Schedule

+ +
+

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.

+
+ +

Stopwatch

+
0:00
+
+
+

Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.

+

Bitmaps

+

Draw / upload

@@ -161,12 +180,12 @@ button.d{color:var(--bad)}

-

Export: one JSON file with the settings and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.

+

Export: one JSON file with the settings, the schedule and rules, and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.