diff --git a/CLAUDE.md b/CLAUDE.md index b4f02e9..8fc1968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,9 +98,15 @@ See README.md for the overview. - All settings are in `config.h`. Keep it free of Arduino types for the same reason. - Frames are only sent when the screen changes. The schedule uses 08:00 as the start of the day on purpose (that is what the old code did, and the README documents it). +- Slow mode (`SlowModeSettings`, replaced the late-evening quarter hours) lives in + `DisplayController::_slowClock()`: one frame per update minute (`slowModeUpdate()`), power + off in between, nothing else drawn (also no page changes). It updates at once after boot, + night, the Power switch and live mode; rules with `over_slow_mode` suspend it + (`setSlowModeOverride()`, fed from `PageChoice` in `appPageLoop()`). - Home Assistant switches are not optimistic: callbacks must call `sender->setState()`. The unique ids (`FlipDotDisplayPower`, `FlipDotDisplayMode`, `FlipDotDisplayOffImage`, - `FlipDotDisplayStopwatch`, ...) and `HA_DEVICE_ID` must stay + `FlipDotDisplayStopwatch`, `FlipDotDisplaySlowMode`, `FlipDotDisplaySlowModeInterval`, + ...) and `HA_DEVICE_ID` must stay the same, or Home Assistant creates new entities. ## Keeping ESP32 OTA alive (most important requirement) diff --git a/Pictures/web-ui/bitmaps.png b/Pictures/web-ui/bitmaps.png index e3d2b62..d9004df 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 6b9b1a7..b19885e 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 efed1c8..6dcb5ad 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 07eb77a..7e499a7 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 5f08cd1..64efdd5 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 d56ff5c..d7ab068 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 778c2a2..b01aa08 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 41bf8a0..3b43cfe 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 7b69c6f..1a9e03c 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 ec46ec3..539d75c 100644 Binary files a/Pictures/web-ui/system.png and b/Pictures/web-ui/system.png differ diff --git a/docs/home-assistant.md b/docs/home-assistant.md index 2fd8b3a..b21ed2c 100644 --- a/docs/home-assistant.md +++ b/docs/home-assistant.md @@ -23,6 +23,10 @@ The ESP32 connects to Home Assistant in two ways: 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. +- `FlipDot Display Slow mode` switch and `FlipDot Display Slow mode interval` dropdown + (`1 minute` ... `60 minutes`): the display only changes at those times, on the clock, and + is switched off in between (less noise, e.g. in the evening with an automation). Rules + with "show at once in slow mode" still show their page right away. - `FlipDot Display Firmware` sensor: version and health, e.g. `2.7.2, ok, 0 crashes, recovery ok`. - `FlipDot Display Recovery` button: restarts into the [recovery app](recovery.md) for 10 minutes. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3e70fc5..f499875 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -10,11 +10,14 @@ connects to MQTT. 2. **Clock.** Once the time is synced, the ESP32 sends a new frame whenever the screen changes, mostly once a minute: - - **08:00–22:00:** the page: the chosen page (by default the built-in Clock page, + - **08:00–23:30:** the page: the chosen page (by default the built-in Clock page, Pikachu with a thought bubble showing `HH:MM`, yawning from 17:00), or what the [schedule and rules](web-ui.md#schedule) pick. - - **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45. - 30 seconds after each of those minutes, the relay turns the display off. + - **Slow mode** (a switch in Home Assistant and the web UI, for less noise): the display + only changes every 1, 2, 5, 10, 15, 20, 30 or 60 minutes, on the clock (15 minutes = + :00, :15, :30 and :45). 30 seconds after each update the relay turns the display off; + the dots keep the image. Page changes wait for the next update too, except for rules + with "show at once in slow mode": while their page is shown, the display works normally. - **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI) and power is cut after 30 seconds. 3. **Home Assistant**: switches, dropdowns and sensors over MQTT, see diff --git a/docs/web-ui.md b/docs/web-ui.md index 6bac53c..7351454 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -66,7 +66,9 @@ Rules bring a page to the front from a Home Assistant state (an entity or attrib 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. 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. +hand"): it then waits until that pick ends (the next block or "Back to schedule"). A rule +with "show at once in slow mode" ticked brings its page right away in +[slow mode](#settings), and the display works normally while that page is shown. The display schedule ([Settings](#settings)) still decides when the display is on. ![Schedule](../Pictures/web-ui/schedule.png) @@ -114,8 +116,9 @@ on the dots. Save it as a bitmap with a name, or as an icon (only the area with ## Settings -The display 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, +The display schedule (on/off, when the display is on, when it switches off); slow mode +(also a Home Assistant switch: the display only changes every 1-60 minutes, on the clock, +and is switched off in between, for less noise); 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; pages can choose their own effect); how often every dot is flipped again ("Dot refresh", default diff --git a/src/esp32/app.cpp b/src/esp32/app.cpp index 0972e4f..50b62b0 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -90,7 +90,7 @@ static void applyOffPage() static void apply() { - controller.setSettings(settings.schedule, settings.clock); + controller.setSettings(settings.schedule, settings.clock, settings.slow_mode); controller.setEffectTime(effectTime(settings.drive)); status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots); frames.setDrive(settings.flip_time, settings.drive); @@ -109,6 +109,9 @@ static void reloadPage() controller.pageChanged(); } +static bool settings_converted = false; +static bool schedule_converted = false; + const Settings &appSettings() { return settings; @@ -116,8 +119,8 @@ const Settings &appSettings() void appLoadSettings() { - loadSettings(settings); - loadSchedule(page_schedule); + settings_converted = loadSettings(settings); + schedule_converted = loadSchedule(page_schedule); selector.setSchedule(&page_schedule); selector.setChosen(settings.page); haApiWatchRules(&page_schedule); @@ -157,6 +160,27 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed) } +void appSetSlowMode(bool enabled) +{ + if (settings.slow_mode.enabled == enabled) + return; + settings.slow_mode.enabled = enabled; + saveSettings(settings); + controller.setSettings(settings.schedule, settings.clock, settings.slow_mode); +} + +const char *appSetSlowModeMinutes(int minutes) +{ + if (!validSlowModeMinutes(minutes)) + return "Unknown slow mode interval"; + if (settings.slow_mode.minutes == minutes) + return nullptr; + settings.slow_mode.minutes = minutes; + saveSettings(settings); + controller.setSettings(settings.schedule, settings.clock, settings.slow_mode); + return nullptr; +} + // The chosen page, without overriding the schedule static void setChosen(const char *name) { @@ -256,6 +280,7 @@ void appPageLoop(bool time_valid, const LocalTime &time) if (!time_valid) return; shown_choice = selector.update(time.hour * 60 + time.minute, now); + controller.setSlowModeOverride(shown_choice.over_slow_mode); if (!shown_choice.changed && sameName(shown, shown_choice.page)) return; copyName(shown, shown_choice.page); @@ -534,6 +559,62 @@ void appMigrateIcons() reloadPage(); } +// Same steps as the icon migration: one page per check, MIGRATION_STEP_MS per pass +static bool data_listed = false; +static bool data_done = false; +static std::vector data_pages; +static size_t data_next = 0; +static int data_left = -1; + +void appMigrateData() +{ + if (data_done || migration != Migration::Done) + return; + uint32_t pass = millis(); + if (settings_converted) + { + saveSettings(settings); + settings_converted = false; + Serial.println("Settings stored in the new layout"); + } + if (schedule_converted) + { + saveSchedule(page_schedule); + schedule_converted = false; + Serial.println("Schedule stored in the new layout"); + } + if (!data_listed) + { + data_pages = storedPageNames(); + data_listed = true; + data_left = 0; + return; + } + DynamicPage *page = new DynamicPage; + while (data_next < data_pages.size() && millis() - pass < MIGRATION_STEP_MS) + { + const char *name = data_pages[data_next++].c_str(); + if (!pageInOldFormat(name)) + continue; + const char *error = loadPage(name, *page) ? savePage(name, *page) : "unreadable"; + Serial.printf("Page \"%s\" in the new format: %s\n", name, error ? error : "ok"); + if (error) + data_left++; + } + delete page; + if (data_next < data_pages.size()) + return; + data_pages.clear(); + data_done = true; +} + +int appOldDataLeft() +{ + if (!data_done) + return -1; + return data_left + (settings_converted ? 1 : 0) + (schedule_converted ? 1 : 0); +} + const char *appSaveFont(const char *name, const uint8_t *data, size_t size) { const char *error = saveFont(name, data, size); diff --git a/src/esp32/app.h b/src/esp32/app.h index 191926a..665ab78 100644 --- a/src/esp32/app.h +++ b/src/esp32/app.h @@ -31,6 +31,11 @@ void appLoadSettings(); // restart_needed when the change only works after a restart (Home Assistant MQTT). const char *appSaveSettings(const Settings &settings, bool &restart_needed); +// Slow mode (Home Assistant switch and dropdown): stored and used right away. minutes: one +// of SLOW_MODE_MINUTES. Return nullptr or an error message. +void appSetSlowMode(bool enabled); +const char *appSetSlowModeMinutes(int minutes); + // 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); @@ -63,6 +68,12 @@ const char *appIconToBitmap(const char *name); // Once: the uploaded bitmaps that only pages use become icons. Call from loop(); returns at // once when it is done. void appMigrateIcons(); +// Once per boot, after the firmware was marked good (the old firmware can't read the new +// formats): settings and schedule read in an older layout are stored again, and pages in an +// older format are rewritten. Call from loop(); returns at once when it is done. +void appMigrateData(); +// Records still in an older format (-1 until appMigrateData() checked the pages) +int appOldDataLeft(); const char *appSavePage(const char *name, const DynamicPage &page); const char *appDeletePage(const char *name); const char *appSaveFont(const char *name, const uint8_t *data, size_t size); diff --git a/src/esp32/config.h b/src/esp32/config.h index 9e1b573..b65a274 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.14.0" +#define FIRMWARE_VERSION "2.15.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 2da4a42..cee628a 100644 --- a/src/esp32/display/page_store.cpp +++ b/src/esp32/display/page_store.cpp @@ -536,6 +536,23 @@ bool loadPage(const char *name, DynamicPage &page) return ok || builtinPage(name, page); } +std::vector storedPageNames() +{ + return storedNames(DIR, EXTENSION); +} + +bool pageInOldFormat(const char *name) +{ + if (!storedPage(name)) + return false; + File file = LittleFS.open(path(name), "r"); + if (!file) + return false; + bool old = file.size() != sizeof(StoredPage); + file.close(); + return old; +} + const char *savePage(const char *name, const DynamicPage &page) { if (!validBitmapName(name)) diff --git a/src/esp32/display/page_store.h b/src/esp32/display/page_store.h index 7208516..7b79996 100644 --- a/src/esp32/display/page_store.h +++ b/src/esp32/display/page_store.h @@ -31,6 +31,10 @@ 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); +// The stored pages (slow: lists the directory) and whether one is stored in an older +// format (loadPage() converts it, savePage() writes the current one) +std::vector storedPageNames(); +bool pageInOldFormat(const char *name); // Return nullptr on success, otherwise a message for the user. savePage() validates the // page, but does not check that its bitmaps and fonts exist. diff --git a/src/esp32/logic/display_controller.cpp b/src/esp32/logic/display_controller.cpp index cdb1aec..7e73fdf 100644 --- a/src/esp32/logic/display_controller.cpp +++ b/src/esp32/logic/display_controller.cpp @@ -7,13 +7,28 @@ DisplayController::DisplayController(DisplayPower &power, Frames &frames) : _pow { } -void DisplayController::setSettings(const ScheduleSettings &schedule, const ClockSettings &clock) +void DisplayController::setSettings(const ScheduleSettings &schedule, const ClockSettings &clock, const SlowModeSettings &slow) { + if (slow.enabled && !_slow.enabled) + _slow_keep = true; _schedule = schedule; _clock = clock; + _slow = slow; _content_revision++; // e.g. the No WiFi setting changed: redraw } +void DisplayController::setSlowModeOverride(bool active) +{ + if (_slow_override && !active) + _slow_last = -1; + _slow_override = active; +} + +bool DisplayController::slowModeActive() const +{ + return _slow.enabled && !_slow_override; +} + void DisplayController::setEffectTime(uint32_t ms) { _effect_time = ms; @@ -40,6 +55,7 @@ void DisplayController::showLive(uint32_t now) void DisplayController::stopLive() { _live = false; + _slow_last = -1; _content_revision++; } @@ -50,6 +66,8 @@ bool DisplayController::liveActive() const void DisplayController::setPowerEnabled(bool enabled) { + if (enabled && !_power_enabled) + _slow_last = -1; _power_enabled = enabled; } @@ -109,6 +127,8 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t switch (scheduleFor(time.hour, time.minute, _schedule)) { case ScheduleMode::Night: + _slow_last = -1; // the morning starts with an update + _slow_keep = false; if (_power.isOn()) { _showOnce(Screen::Off); @@ -116,6 +136,11 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t } break; case ScheduleMode::Clock: + if (slowModeActive()) + { + _slowClock(now, time, show_no_wifi); + break; + } _off_timer.cancel(); _ensureOn(); if (_page == Page::Dynamic) @@ -123,14 +148,38 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t else _showOnce(Screen::Page); break; - case ScheduleMode::Standby: - // Flipdots keep their image without power, so the last image stays visible - if (_power.isOn()) - _off_timer.request(now, POWER_OFF_DELAY); - break; } } +// Slow mode: one frame in each update minute, then the power goes off. Flipdots keep their +// image without power, so it stays visible until the next update. +void DisplayController::_slowClock(uint32_t now, const LocalTime &time, bool no_wifi) +{ + int minute = time.hour * 60 + time.minute; + if (_slow_keep) + { + _slow_keep = false; + // Not the boot screen or an off screen: that is replaced right away + if (_power.isOn() && (_screen == Screen::Dynamic || _screen == Screen::NoWifi || _screen == Screen::Page)) + _slow_last = minute; + } + bool due = _slow_last < 0 || (minute != _slow_last && slowModeUpdate(time.hour, time.minute, _slow.minutes)); + if (due) + { + _slow_last = minute; + _off_timer.cancel(); + _ensureOn(); + _screen = Screen::Unknown; // the whole image, also when it looks the same + if (_page == Page::Dynamic) + _showDynamic(now, time, no_wifi); + else + _showOnce(Screen::Page); + _dynamic_moving = false; // moving pages stand still until the next update + } + if (_power.isOn()) + _off_timer.request(now, POWER_OFF_DELAY + _effect_time); +} + void DisplayController::_ensureOn() { if (_power.isOn()) diff --git a/src/esp32/logic/display_controller.h b/src/esp32/logic/display_controller.h index 444643a..7e66d37 100644 --- a/src/esp32/logic/display_controller.h +++ b/src/esp32/logic/display_controller.h @@ -49,7 +49,15 @@ class DisplayController public: DisplayController(DisplayPower &power, Frames &frames); - void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock); + // Slow mode: the display only changes in the minutes of slowModeUpdate() (once, also + // when the page changed in between) and is powered off in between. Switching it on keeps + // the image until then; switching it off updates right away. + void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock, const SlowModeSettings &slow); + // A rule that overrides slow mode shows its page: the display works normally meanwhile, + // and is updated right away when it ends + void setSlowModeOverride(bool active); + // Slow mode is switched on and no rule overrides it + bool slowModeActive() const; // How long the Nano takes to show a frame with the refresh effect (ms, 0 = instant). // The power stays on that much longer after the last frame. void setEffectTime(uint32_t ms); @@ -91,6 +99,10 @@ private: ScheduleSettings _schedule; ClockSettings _clock; + SlowModeSettings _slow; + int _slow_last = -1; // slow mode: minute of the day of the last update, -1 = update now + bool _slow_keep = false; // slow mode was switched on: the current image counts as updated + bool _slow_override = false; Page _page = Page::Dynamic; uint32_t _effect_time = 0; @@ -110,6 +122,7 @@ private: bool _wifi_lost = false; uint32_t _wifi_lost_since = 0; + void _slowClock(uint32_t now, const LocalTime &time, bool no_wifi); void _ensureOn(); void _showDynamic(uint32_t now, const LocalTime &time, bool no_wifi); void _showOnce(Screen screen); diff --git a/src/esp32/logic/page_schedule.h b/src/esp32/logic/page_schedule.h index 8edf810..dce253b 100644 --- a/src/esp32/logic/page_schedule.h +++ b/src/esp32/logic/page_schedule.h @@ -65,6 +65,7 @@ struct TriggerRule 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) + bool over_slow_mode = false; // while its page is shown the display works normally (slow mode waits) }; struct PageSchedule diff --git a/src/esp32/logic/page_selector.cpp b/src/esp32/logic/page_selector.cpp index 8050788..b8d5601 100644 --- a/src/esp32/logic/page_selector.cpp +++ b/src/esp32/logic/page_selector.cpp @@ -159,6 +159,7 @@ PageChoice PageSelector::update(int minute, uint32_t now) } choice.changed = !sameName(choice.page, _shown); + choice.over_slow_mode = choice.rule >= 0 && choice.rule < rule_count && _schedule->rules[choice.rule].over_slow_mode; // 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); diff --git a/src/esp32/logic/page_selector.h b/src/esp32/logic/page_selector.h index b0232fb..bb576f7 100644 --- a/src/esp32/logic/page_selector.h +++ b/src/esp32/logic/page_selector.h @@ -29,6 +29,7 @@ struct PageChoice 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 + bool over_slow_mode = false; // shown by a rule that overrides slow mode }; // The chosen page also rotates with AddToRotation pages, this long diff --git a/src/esp32/logic/schedule.cpp b/src/esp32/logic/schedule.cpp index 0056672..250041f 100644 --- a/src/esp32/logic/schedule.cpp +++ b/src/esp32/logic/schedule.cpp @@ -8,7 +8,10 @@ ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &s) int now = hour * 60 + minute; if (now < s.day_start || now >= s.night_start) return ScheduleMode::Night; - if (now < s.late_start || !s.quarter_hours || minute % 15 == 0) - return ScheduleMode::Clock; - return ScheduleMode::Standby; + return ScheduleMode::Clock; +} + +bool slowModeUpdate(int hour, int minute, int interval) +{ + return interval <= 1 || (hour * 60 + minute) % interval == 0; } diff --git a/src/esp32/logic/schedule.h b/src/esp32/logic/schedule.h index f4e5845..44136ef 100644 --- a/src/esp32/logic/schedule.h +++ b/src/esp32/logic/schedule.h @@ -6,16 +6,20 @@ // settings: // // 00:00 - 07:59 Night screen cleared, power off -// 08:00 - 21:59 Clock page shown (a dynamic page is updated every minute) -// 22:00 - 23:30 Clock only during the minutes :00, :15, :30 and :45 -// Standby in between: power off, the last image stays on the dots +// 08:00 - 23:30 Clock page shown (a dynamic page is updated every minute) // 23:31 - 23:59 Night +// +// Slow mode (SlowModeSettings, handled by DisplayController) only changes how often the +// page is drawn during Clock. enum class ScheduleMode { Night, Clock, - Standby, }; ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &settings); + +// Slow mode: true in the minutes when the display is updated (interval: one of +// SLOW_MODE_MINUTES, counted from midnight, so 15 = :00 :15 :30 :45) +bool slowModeUpdate(int hour, int minute, int interval); diff --git a/src/esp32/logic/settings.cpp b/src/esp32/logic/settings.cpp index b7dceee..818fbeb 100644 --- a/src/esp32/logic/settings.cpp +++ b/src/esp32/logic/settings.cpp @@ -20,10 +20,12 @@ const char *validateSettings(const Settings &s) return "Unknown off image"; const ScheduleSettings &t = s.schedule; - if (t.day_start >= MINUTES_PER_DAY || t.late_start > MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY) + if (t.day_start >= MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY) return "Times must be between 00:00 and 24:00"; - if (t.day_start > t.late_start || t.late_start > t.night_start) - return "The times must be in order: day start, late evening, night"; + if (t.day_start > t.night_start) + return "The night must start after the day"; + if (!validSlowModeMinutes(s.slow_mode.minutes)) + return "Unknown slow mode interval"; if (s.flip_time < SETTINGS_FLIP_TIME_MIN || s.flip_time > SETTINGS_FLIP_TIME_MAX) return "The flip time must be between 50 and 2000 us"; @@ -53,6 +55,14 @@ const char *validateSettings(const Settings &s) return nullptr; } +bool validSlowModeMinutes(int minutes) +{ + for (uint8_t m : SLOW_MODE_MINUTES) + if (m == minutes) + return true; + return false; +} + uint32_t effectTime(const DriveSettings &drive) { if (drive.effect == DRIVE_EFFECT_INSTANT) diff --git a/src/esp32/logic/settings.h b/src/esp32/logic/settings.h index 0466cb6..5e63408 100644 --- a/src/esp32/logic/settings.h +++ b/src/esp32/logic/settings.h @@ -14,11 +14,21 @@ struct ScheduleSettings { bool enabled = true; // false: the display is always on uint16_t day_start = 8 * 60; // display on from - uint16_t late_start = 22 * 60; // from here only at the quarter hours (see quarter_hours) - bool quarter_hours = true; // false: on all the time until night_start uint16_t night_start = 23 * 60 + 31; // display off from (up to 24:00 = never) }; +// Slow mode (Home Assistant switch "FlipDot Display Slow mode", web UI): the display only +// changes every `minutes` minutes, on the clock (15: at :00 :15 :30 :45), and is powered +// off in between. Fewer flips, so less noise. +const uint8_t SLOW_MODE_MINUTES[] = {1, 2, 5, 10, 15, 20, 30, 60}; // all divide an hour +const int SLOW_MODE_MINUTES_COUNT = sizeof(SLOW_MODE_MINUTES) / sizeof(SLOW_MODE_MINUTES[0]); +struct SlowModeSettings +{ + bool enabled = false; + uint8_t minutes = 15; // one of SLOW_MODE_MINUTES +}; +bool validSlowModeMinutes(int minutes); + struct ClockSettings { bool no_wifi_screen = true; // "No WiFi" instead of a dynamic page when WiFi is down @@ -85,6 +95,7 @@ struct Settings RefreshSettings refresh; // Plex user name for {plex_user} in entity ids (Tautulli's sensors of that user) char plex_user[PLEX_USER_SIZE] = ""; + SlowModeSettings slow_mode; // New fields go here, at the end (see system/settings_store.cpp) }; diff --git a/src/esp32/main.cpp b/src/esp32/main.cpp index c79c299..0304f98 100644 --- a/src/esp32/main.cpp +++ b/src/esp32/main.cpp @@ -87,7 +87,10 @@ void loop() appLoop(); // Once the firmware can't be rolled back anymore (the old one doesn't know icons) if (!firmwareGuardPending()) + { appMigrateIcons(); + appMigrateData(); + } // Home Assistant values of the shown page (read by a task, never blocks) if (haApiLoop(wifiConnected())) controller.pageChanged(); diff --git a/src/esp32/net/home_assistant.cpp b/src/esp32/net/home_assistant.cpp index 45938e8..a6d659e 100644 --- a/src/esp32/net/home_assistant.cpp +++ b/src/esp32/net/home_assistant.cpp @@ -32,6 +32,8 @@ static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch"); static HAButton stopwatchReset("FlipDotDisplayStopwatchReset"); static HASensor shownSensor("FlipDotDisplayShownPage"); static HAButton backButton("FlipDotDisplayBackToSchedule"); +static HASwitch slowSwitch("FlipDotDisplaySlowMode"); +static HAPageSelect slowSelect("FlipDotDisplaySlowModeInterval"); // Removed entities: their retained discovery config is cleared, so Home Assistant // deletes them instead of showing them as unavailable @@ -180,6 +182,29 @@ static NameChoice mode = {modeSelect, nullptr, nullptr, DEFAULT_PAGE, pageNames, static NameChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, "Blank", bitmapNames, []() { return appSettings().off_page; }, appSelectOffPage}; +// The slow mode interval as dropdown options ("15 minutes") +static void slowOption(int minutes, char *out, size_t size) +{ + snprintf(out, size, minutes == 1 ? "%d minute" : "%d minutes", minutes); +} + +static int slow_shown_minutes = -1; + +// Reports slow mode (also when it was changed in the web UI) +static void slowModeUpdate() +{ + const SlowModeSettings &slow = appSettings().slow_mode; + if (slowSwitch.getCurrentState() != slow.enabled) + slowSwitch.setState(slow.enabled); + if (slow_shown_minutes != slow.minutes) + { + slow_shown_minutes = slow.minutes; + char option[16]; + slowOption(slow.minutes, option, sizeof(option)); + slowSelect.setState(option); + } +} + static void clearRemovedEntities() { for (const char *id : REMOVED_SWITCHES) @@ -242,6 +267,34 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin backButton.setIcon("mdi:calendar-clock"); backButton.onCommand([](HAButton *) { appBackToSchedule(); }); + slowSwitch.setName("FlipDot Display Slow mode"); + slowSwitch.setIcon("mdi:snail"); + slowSwitch.setCurrentState(appSettings().slow_mode.enabled); + slowSwitch.onCommand([](bool state, HASwitch *sender) { + appSetSlowMode(state); + sender->setState(appSettings().slow_mode.enabled); + }); + slowSelect.setName("FlipDot Display Slow mode interval"); + slowSelect.setIcon("mdi:timer-sand"); + { + std::vector options; + char option[16]; + for (uint8_t minutes : SLOW_MODE_MINUTES) + { + slowOption(minutes, option, sizeof(option)); + options.push_back(option); + } + slowSelect.setOptions(options); + } + slowSelect.onCommand([](const char *option) { + const char *error = appSetSlowModeMinutes(atoi(option)); + if (error) + Serial.printf("Home Assistant \"%s\": %s\n", option, error); + slow_shown_minutes = -1; // report the (new) state back + slowModeUpdate(); + }); + slowModeUpdate(); + firmwareSensor.setName("FlipDot Display Firmware"); firmwareSensor.setIcon("mdi:chip"); @@ -286,6 +339,7 @@ void homeAssistantLoop() firmware.loop(); mode.update(); offImage.update(); + slowModeUpdate(); // Started or stopped in the web UI if (stopwatchSwitch.getCurrentState() != stopwatch.running()) stopwatchSwitch.setState(stopwatch.running()); diff --git a/src/esp32/net/home_assistant.h b/src/esp32/net/home_assistant.h index bf69592..2a62f26 100644 --- a/src/esp32/net/home_assistant.h +++ b/src/esp32/net/home_assistant.h @@ -16,6 +16,8 @@ // 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) +// FlipDot Display Slow mode switch: the display only changes every few minutes +// FlipDot Display Slow mode interval select: how often ("15 minutes"; settings.slow_mode) // // 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 index 33ec6b3..a278553 100644 --- a/src/esp32/system/schedule_store.cpp +++ b/src/esp32/system/schedule_store.cpp @@ -6,10 +6,7 @@ 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 = 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"); +static const uint16_t VERSION = 4; struct StoredSchedule { @@ -72,11 +69,74 @@ static void convert(const Schedule &old, PageSchedule &s) } } // namespace v1 -static_assert(sizeof(v1::Stored) != sizeof(StoredSchedule), "the versions are told apart by their size"); +// Versions 2 and 3: the rules had no over_slow_mode. Version 2 had the same layout as 3, but +// over_manual was padding (set to true). Frozen copy, converted. +namespace v3 +{ +struct Rule +{ + char entity[ELEMENT_ENTITY_SIZE]; + char attribute[ELEMENT_ATTRIBUTE_SIZE]; + Compare compare; + char value[RULE_VALUE_SIZE]; + char page[PAGE_NAME_SIZE]; + TriggerMode mode; + uint16_t seconds; + uint16_t interval; + uint8_t blocks; + bool over_manual; +}; +static_assert(sizeof(Rule) == 164, "the frozen layout of versions 2 and 3"); -void loadSchedule(PageSchedule &schedule) +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, uint16_t version, 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; + r.blocks = o.blocks; + r.over_manual = version == 2 ? true : o.over_manual; // over_slow_mode: false + } +} +} // namespace v3 + +static_assert(sizeof(v1::Stored) != sizeof(StoredSchedule) && sizeof(v3::Stored) != sizeof(StoredSchedule) && + sizeof(v1::Stored) != sizeof(v3::Stored), + "the versions are told apart by their size"); + +bool loadSchedule(PageSchedule &schedule) { schedule = PageSchedule(); + bool converted = false; Preferences prefs; prefs.begin(NAMESPACE, true); size_t length = prefs.isKey(KEY) ? prefs.getBytesLength(KEY) : 0; @@ -84,31 +144,47 @@ void loadSchedule(PageSchedule &schedule) { StoredSchedule *stored = new StoredSchedule; if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) && - (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; + stored->version == VERSION && stored->size == sizeof(PageSchedule) && + validateSchedule(stored->schedule) == nullptr) schedule = stored->schedule; + delete stored; + } + else if (length == sizeof(v3::Stored)) + { + v3::Stored *stored = new v3::Stored; + PageSchedule *page = new PageSchedule; + if (prefs.getBytes(KEY, stored, sizeof(v3::Stored)) == sizeof(v3::Stored) && + (stored->version == 2 || stored->version == 3) && stored->size == sizeof(v3::Schedule)) + { + v3::convert(stored->schedule, stored->version, *page); + if (validateSchedule(*page) == nullptr) + { + schedule = *page; + converted = true; + } } + delete page; delete stored; } else if (length == sizeof(v1::Stored)) { v1::Stored *stored = new v1::Stored; - PageSchedule *converted = new PageSchedule; + PageSchedule *page = 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; + v1::convert(stored->schedule, *page); + if (validateSchedule(*page) == nullptr) + { + schedule = *page; + converted = true; + } } - delete converted; + delete page; delete stored; } prefs.end(); + return converted; } void saveSchedule(const PageSchedule &schedule) diff --git a/src/esp32/system/schedule_store.h b/src/esp32/system/schedule_store.h index de0614f..e88b307 100644 --- a/src/esp32/system/schedule_store.h +++ b/src/esp32/system/schedule_store.h @@ -3,6 +3,7 @@ #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); +// missing, unknown or invalid record gives an empty schedule. Returns true when the record +// had an older layout and was converted (save it again to keep it). +bool loadSchedule(PageSchedule &schedule); void saveSchedule(const PageSchedule &schedule); diff --git a/src/esp32/system/settings_store.cpp b/src/esp32/system/settings_store.cpp index 360ab57..5064968 100644 --- a/src/esp32/system/settings_store.cpp +++ b/src/esp32/system/settings_store.cpp @@ -9,7 +9,7 @@ static const char *KEY = "settings"; // Increase when the Settings struct changes; old records are then ignored (defaults), // unless the code below says how to read them. When fields are only added at the end, // an old record is the start of the new layout: add an OldVersion for it like v3 does. -static const uint16_t VERSION = 6; +static const uint16_t VERSION = 7; struct OldVersion { @@ -17,6 +17,59 @@ struct OldVersion size_t fields; // bytes of the settings struct that version had }; +// Versions 4-6: the layout before slow mode (version 7 removed the late-evening quarter +// hours of the schedule and added slow_mode). Frozen copy; versions 4 and 5 lacked refresh +// and plex_user (added at the end, so they are a prefix of it). +namespace v6 +{ +struct ScheduleSettings +{ + bool enabled = true; + uint16_t day_start = 8 * 60; + uint16_t late_start = 22 * 60; + bool quarter_hours = true; + uint16_t night_start = 23 * 60 + 31; +}; + +struct Settings +{ + char page[PAGE_NAME_SIZE] = DEFAULT_PAGE; + ScheduleSettings schedule; + ClockSettings clock; + HomeAssistantSettings home_assistant; + uint16_t flip_time = 225; + bool status_led = true; + char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK; + DriveSettings drive; + RefreshSettings refresh; + char plex_user[PLEX_USER_SIZE] = ""; +}; + +static const OldVersion VERSIONS[] = { + {4, offsetof(Settings, refresh)}, + {5, offsetof(Settings, plex_user)}, + {6, sizeof(Settings)}, +}; + +static void convert(const Settings &old, ::Settings &s) +{ + s = ::Settings(); + memcpy(s.page, old.page, sizeof(s.page)); + s.schedule.enabled = old.schedule.enabled; + s.schedule.day_start = old.schedule.day_start; + s.schedule.night_start = old.schedule.night_start; + s.clock = old.clock; + s.home_assistant = old.home_assistant; + s.flip_time = old.flip_time; + s.status_led = old.status_led; + memcpy(s.off_page, old.off_page, sizeof(s.off_page)); + s.drive = old.drive; + s.refresh = old.refresh; + memcpy(s.plex_user, old.plex_user, sizeof(s.plex_user)); + // The quarter hours are replaced by slow mode, which starts off +} +} // namespace v6 + // Versions 1-3: the layout before the dynamic pages (version 4 removed the evening // settings of the clock and added the Home Assistant API). Frozen copy, read as a prefix // (version 1 lacked off_page and drive, version 2 drive) and converted. @@ -41,7 +94,7 @@ struct HomeAssistantSettings struct Settings { char page[25] = "clock"; - ScheduleSettings schedule; + v6::ScheduleSettings schedule; ClockSettings clock; HomeAssistantSettings home_assistant; uint16_t flip_time = 225; @@ -56,9 +109,9 @@ static const OldVersion VERSIONS[] = { {3, sizeof(Settings)}, }; -static void convert(const Settings &old, ::Settings &s) +static void convert(const Settings &old, v6::Settings &s) { - s = ::Settings(); + s = v6::Settings(); memcpy(s.page, old.page, sizeof(s.page)); s.schedule = old.schedule; s.clock.no_wifi_screen = old.clock.no_wifi_screen; @@ -76,20 +129,19 @@ static void convert(const Settings &old, ::Settings &s) } } // namespace v3 -static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer"); +static constexpr size_t maxSize(size_t a, size_t b) +{ + return a > b ? a : b; +} -// Versions 4 and 5: the current layout without refresh and plex_user (added at the end, so -// they are a prefix of it) -static const OldVersion PREFIXES[] = { - {4, offsetof(Settings, refresh)}, - {5, offsetof(Settings, plex_user)}, -}; +// Big enough for every layout (an old record can be longer than the current one) +static const size_t RECORD_SIZE = maxSize(sizeof(Settings), maxSize(sizeof(v6::Settings), sizeof(v3::Settings))); struct StoredSettings { uint16_t version; uint16_t size; - Settings settings; + alignas(4) uint8_t settings[RECORD_SIZE]; }; // Reads a record whose first `fields` bytes have the layout of T @@ -98,11 +150,11 @@ template static bool readOld(const StoredSettings &stored, size_t r if (stored.size < fields || stored.size > sizeof(T) || read != offsetof(StoredSettings, settings) + stored.size) return false; out = T(); - memcpy((void *)&out, &stored.settings, fields); + memcpy((void *)&out, stored.settings, fields); return true; } -void loadSettings(Settings &settings) +bool loadSettings(Settings &settings) { settings = Settings(); StoredSettings stored; @@ -113,33 +165,36 @@ void loadSettings(Settings &settings) prefs.end(); Settings loaded; - if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings)) - loaded = stored.settings; - else if (stored.version >= 4 && stored.version < VERSION) + bool converted = false; + if (stored.version == VERSION) { - bool ok = false; - for (const OldVersion &o : PREFIXES) - if (o.version == stored.version) - ok = readOld(stored, read, o.fields, loaded); - if (!ok) - return; + if (stored.size != sizeof(Settings) || !readOld(stored, read, sizeof(Settings), loaded)) + return false; } else { bool ok = false; + v6::Settings old; + for (const OldVersion &o : v6::VERSIONS) + if (o.version == stored.version) + ok = readOld(stored, read, o.fields, old); for (const OldVersion &o : v3::VERSIONS) if (o.version == stored.version) { - v3::Settings old; - ok = readOld(stored, read, o.fields, old); + v3::Settings older; + ok = readOld(stored, read, o.fields, older); if (ok) - v3::convert(old, loaded); + v3::convert(older, old); } if (!ok) - return; + return false; + v6::convert(old, loaded); + converted = true; } - if (validateSettings(loaded) == nullptr) - settings = loaded; + if (validateSettings(loaded) != nullptr) + return false; + settings = loaded; + return converted; } void saveSettings(const Settings &settings) @@ -147,9 +202,9 @@ void saveSettings(const Settings &settings) StoredSettings stored; stored.version = VERSION; stored.size = sizeof(Settings); - stored.settings = settings; + memcpy(stored.settings, (const void *)&settings, sizeof(Settings)); Preferences prefs; prefs.begin(NAMESPACE, false); - prefs.putBytes(KEY, &stored, sizeof(stored)); + prefs.putBytes(KEY, &stored, offsetof(StoredSettings, settings) + sizeof(Settings)); prefs.end(); } diff --git a/src/esp32/system/settings_store.h b/src/esp32/system/settings_store.h index 60bca7a..39d1212 100644 --- a/src/esp32/system/settings_store.h +++ b/src/esp32/system/settings_store.h @@ -2,6 +2,7 @@ #include "logic/settings.h" -// The web UI settings in NVS. A missing or outdated record gives the defaults. -void loadSettings(Settings &settings); +// The web UI settings in NVS. A missing or unknown record gives the defaults. Returns true +// when the record had an older layout and was converted (save it again to keep it). +bool loadSettings(Settings &settings); void saveSettings(const Settings &settings); diff --git a/src/esp32/web/schedule_json.cpp b/src/esp32/web/schedule_json.cpp index 005a379..00b8c6a 100644 --- a/src/esp32/web/schedule_json.cpp +++ b/src/esp32/web/schedule_json.cpp @@ -60,6 +60,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out) o["seconds"] = r.seconds; o["interval"] = r.interval; o["over_manual"] = r.over_manual; + o["over_slow_mode"] = r.over_slow_mode; JsonArray in_blocks = o["blocks"].to(); for (int b = 0; b < s.block_count; b++) if ((r.blocks >> b) & 1) @@ -113,6 +114,7 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s) r.seconds = constrain(o["seconds"] | 30, 0, 65535); r.interval = constrain(o["interval"] | 10, 0, 65535); r.over_manual = o["over_manual"] | true; + r.over_slow_mode = o["over_slow_mode"] | false; // The blocks by their start time (none: always) for (JsonVariantConst start : o["blocks"].as()) { diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 407682e..8bb486f 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -150,10 +150,12 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m

Schedule

- -
+

Slow mode

+ + +

Fewer flips, less noise: the display only changes at these times (on the clock, 15 minutes = :00 :15 :30 :45) and is switched off in between; the dots keep the image. Page changes wait too, except for rules with "show at once in slow mode".

Without WiFi

The backgrounds per time of day (like the yawning Pikachu) are set per page in the Pages tab.

@@ -344,6 +346,8 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab '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)'})), + mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:!!ru.over_slow_mode});c.onchange=()=>{ru.over_slow_mode=c.checked;schedDirty=true}; + return mk('label',{},c,' show at once in slow mode')})(),mk('span',{className:'hint',textContent:'(the display works normally while this page is shown)'})), 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)'})):''))}); @@ -351,7 +355,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return} sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()} function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return} - sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true});schedDirty=true;schedBuild()} + sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true,over_slow_mode:false});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){}} @@ -635,15 +639,17 @@ async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live( await step();testTimer=setInterval(step,Math.max(4000,(settings.drive.effect?settings.drive.effect_time:0)+2000))} async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()} // settings -function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;$('s_late').value=s.schedule.late_start; - $('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start; +function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start; + $('s_night').value=s.schedule.night_start;$('s_slow').checked=s.slow_mode.enabled;$('s_slowmin').value=s.slow_mode.minutes; $('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led; $('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image; $('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user; $('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;$('ha_plex').value=s.plex_user||''; $('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'} function collect(){const s=JSON.parse(JSON.stringify(settings)); - Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()}); + Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,night_start:$('s_night').value.trim()}); + const sm={enabled:$('s_slow').checked,minutes:+$('s_slowmin').value}; + if(sm.enabled!=settings.slow_mode.enabled||sm.minutes!=settings.slow_mode.minutes)s.slow_mode=sm;else delete s.slow_mode; // HA may have switched it meanwhile s.clock={no_wifi_screen:$('c_nw').checked}; s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked; s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value}; diff --git a/src/esp32/web/web_ui.cpp b/src/esp32/web/web_ui.cpp index 7c8ae8f..de9d6d4 100644 --- a/src/esp32/web/web_ui.cpp +++ b/src/esp32/web/web_ui.cpp @@ -167,6 +167,8 @@ static void handleStatus() doc["shown_status"] = why; doc["off_page"] = appSettings().off_page; doc["power_enabled"] = controller.powerEnabled(); + doc["slow_mode"] = controller.slowModeActive(); + doc["old_data_left"] = appOldDataLeft(); JsonObject sw = doc["stopwatch"].to(); sw["running"] = stopwatch.running(); sw["ms"] = stopwatch.elapsed(millis()); @@ -687,12 +689,13 @@ static void settingsToJson(const Settings &s, JsonObject o) schedule["enabled"] = s.schedule.enabled; formatTime(s.schedule.day_start, time); schedule["day_start"] = String(time); - formatTime(s.schedule.late_start, time); - schedule["late_start"] = String(time); - schedule["quarter_hours"] = s.schedule.quarter_hours; formatTime(s.schedule.night_start, time); schedule["night_start"] = String(time); + JsonObject slow = o["slow_mode"].to(); + slow["enabled"] = s.slow_mode.enabled; + slow["minutes"] = s.slow_mode.minutes; + JsonObject clock = o["clock"].to(); clock["no_wifi_screen"] = s.clock.no_wifi_screen; @@ -754,10 +757,11 @@ static void handleSettingsPost() JsonVariantConst schedule = body["schedule"]; copyBool(schedule["enabled"], s.schedule.enabled); - copyBool(schedule["quarter_hours"], s.schedule.quarter_hours); copyBool(body["clock"]["no_wifi_screen"], s.clock.no_wifi_screen); - if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["late_start"], s.schedule.late_start) || - !copyTime(schedule["night_start"], s.schedule.night_start)) + copyBool(body["slow_mode"]["enabled"], s.slow_mode.enabled); + if (body["slow_mode"]["minutes"].is()) + s.slow_mode.minutes = constrain(body["slow_mode"]["minutes"].as(), 0, 255); + if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["night_start"], s.schedule.night_start)) { sendError(400, "Times must look like 08:30 (00:00 - 24:00)"); return; diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index a5c59ab..5c72495 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -135,18 +135,27 @@ void test_schedule_clock_from_8_to_22() TEST_ASSERT_TRUE(scheduleFor(21, 59, defaults.schedule) == ScheduleMode::Clock); } -void test_schedule_quarter_hours_until_23_30() +void test_schedule_clock_until_23_30() { - TEST_ASSERT_TRUE(scheduleFor(22, 0, defaults.schedule) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(22, 14, defaults.schedule) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(22, 15, defaults.schedule) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(22, 45, defaults.schedule) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(23, 0, defaults.schedule) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Standby); + TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Clock); TEST_ASSERT_TRUE(scheduleFor(23, 30, defaults.schedule) == ScheduleMode::Clock); } +void test_slow_mode_update_minutes() +{ + TEST_ASSERT_TRUE(slowModeUpdate(22, 0, 15)); + TEST_ASSERT_FALSE(slowModeUpdate(22, 1, 15)); + TEST_ASSERT_FALSE(slowModeUpdate(22, 14, 15)); + TEST_ASSERT_TRUE(slowModeUpdate(22, 15, 15)); + TEST_ASSERT_TRUE(slowModeUpdate(22, 45, 15)); + TEST_ASSERT_TRUE(slowModeUpdate(12, 7, 1)); + TEST_ASSERT_TRUE(slowModeUpdate(12, 40, 20)); + TEST_ASSERT_FALSE(slowModeUpdate(12, 30, 20)); + TEST_ASSERT_TRUE(slowModeUpdate(13, 0, 60)); + TEST_ASSERT_FALSE(slowModeUpdate(13, 30, 60)); +} + void test_schedule_night_from_23_31() { TEST_ASSERT_TRUE(scheduleFor(23, 31, defaults.schedule) == ScheduleMode::Night); @@ -387,36 +396,165 @@ void test_night_does_not_power_on() TEST_ASSERT_EQUAL(0, (int)frames.shown.size()); } -void test_standby_keeps_the_last_time_and_powers_off() +static void slowMode(bool enabled, uint8_t minutes = 15) { + SlowModeSettings slow; + slow.enabled = enabled; + slow.minutes = minutes; + controller->setSettings(defaults.schedule, defaults.clock, slow); +} + +void test_slow_mode_keeps_the_last_time_and_powers_off() +{ + slowMode(true); run(22, 0); run(22, 1, POWER_OFF_DELAY + 200); TEST_ASSERT_FALSE(power.is_on); TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no off screen + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); } -void test_quarter_hour_powers_on_again_and_redraws() +void test_slow_mode_powers_on_again_and_redraws() { + slowMode(true); + run(22, 0); run(22, 1, POWER_OFF_DELAY + 200); run(22, 14); TEST_ASSERT_FALSE(power.is_on); run(22, 15); TEST_ASSERT_TRUE(power.is_on); TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str()); + TEST_ASSERT_TRUE(frames.last_redraw); + run(22, 15, POWER_OFF_DELAY + 200); + TEST_ASSERT_FALSE(power.is_on); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); +} + +void test_slow_mode_other_intervals() +{ + slowMode(true, 5); + run(12, 0); + run(12, 4, POWER_OFF_DELAY + 200); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); + run(12, 5); + TEST_ASSERT_EQUAL_STRING("clock 12:05", frames.last().c_str()); + slowMode(false); + slowMode(true, 1); // switched on again: keeps 12:05 until the next minute + run(12, 5, 1000); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); + run(12, 6); + TEST_ASSERT_EQUAL_STRING("clock 12:06", frames.last().c_str()); +} + +void test_slow_mode_switched_on_keeps_the_image() +{ + run(12, 3); + slowMode(true); + run(12, 4, POWER_OFF_DELAY + 200); + TEST_ASSERT_FALSE(power.is_on); + TEST_ASSERT_EQUAL_STRING("clock 12:03", frames.last().c_str()); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); + run(12, 15); + TEST_ASSERT_EQUAL_STRING("clock 12:15", frames.last().c_str()); +} + +void test_slow_mode_after_boot_updates_at_once() +{ + slowMode(true); // stored setting, before the first update + run(12, 7); + TEST_ASSERT_TRUE(power.is_on); + TEST_ASSERT_EQUAL_STRING("clock 12:07", frames.last().c_str()); + run(12, 8, POWER_OFF_DELAY + 200); + TEST_ASSERT_FALSE(power.is_on); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); +} + +void test_slow_mode_page_changes_wait() +{ + slowMode(true); + run(12, 0, POWER_OFF_DELAY + 200); + controller->setPage(Page::Bitmap); + run(12, 5, 1000); + controller->pageChanged(); + run(12, 6, 1000); + TEST_ASSERT_FALSE(power.is_on); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); + run(12, 15); + TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str()); +} + +void test_slow_mode_freezes_moving_pages() +{ + frames.wait = 1000; // a scrolling page + slowMode(true); + run(12, 0, 20000); + TEST_ASSERT_EQUAL(1, (int)frames.shown.size()); +} + +void test_slow_mode_switched_off_updates_at_once() +{ + slowMode(true); + run(12, 0, POWER_OFF_DELAY + 200); + run(12, 7); + slowMode(false); + run(12, 7); + TEST_ASSERT_TRUE(power.is_on); + TEST_ASSERT_EQUAL_STRING("clock 12:07", frames.last().c_str()); + run(12, 8, POWER_OFF_DELAY + 200); + TEST_ASSERT_TRUE(power.is_on); // normal again + TEST_ASSERT_EQUAL_STRING("clock 12:08", frames.last().c_str()); +} + +void test_slow_mode_power_switch_on_updates_at_once() +{ + slowMode(true); + run(12, 0); + controller->setPowerEnabled(false); + run(12, 1, CLEAR_BEFORE_OFF_TIME + 200); + TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str()); + controller->setPowerEnabled(true); + run(12, 2); + TEST_ASSERT_EQUAL_STRING("clock 12:02", frames.last().c_str()); +} + +void test_slow_mode_override_by_a_rule() +{ + slowMode(true); + run(12, 0, POWER_OFF_DELAY + 200); + TEST_ASSERT_TRUE(controller->slowModeActive()); + controller->setSlowModeOverride(true); + controller->setPage(Page::Bitmap); // the rule's page + run(12, 7); + TEST_ASSERT_TRUE(power.is_on); + TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str()); + TEST_ASSERT_FALSE(controller->slowModeActive()); + run(12, 8, POWER_OFF_DELAY + 200); + TEST_ASSERT_TRUE(power.is_on); // works normally while the rule shows its page + + controller->setSlowModeOverride(false); + controller->setPage(Page::Dynamic); + run(12, 9); + TEST_ASSERT_EQUAL_STRING("clock 12:09", frames.last().c_str()); // back at once + run(12, 10, POWER_OFF_DELAY + 200); + TEST_ASSERT_FALSE(power.is_on); // then slow again + TEST_ASSERT_EQUAL_STRING("clock 12:09", frames.last().c_str()); } void test_clock_cancels_pending_power_off() { + slowMode(true); run(22, 0); - run(22, 14, 10000); // power off countdown running + run(22, 1, 10000); // power off countdown running TEST_ASSERT_TRUE(power.is_on); - run(22, 15, POWER_OFF_DELAY); + slowMode(false); + run(22, 1, POWER_OFF_DELAY); TEST_ASSERT_TRUE(power.is_on); TEST_ASSERT_EQUAL(0, power.off_count); } void test_power_switch_off_clears_and_powers_off_quickly() { + slowMode(true); run(12, 0); run(22, 14, 1000); // 30 s countdown already running controller->setPowerEnabled(false); @@ -528,10 +666,7 @@ void test_default_settings_are_valid() void test_settings_times_must_be_in_order() { Settings s; - s.schedule.late_start = 7 * 60; // before day_start - TEST_ASSERT_NOT_NULL(validateSettings(s)); - s = Settings(); - s.schedule.night_start = 21 * 60; // before late_start + s.schedule.night_start = 7 * 60; // before day_start TEST_ASSERT_NOT_NULL(validateSettings(s)); s = Settings(); s.schedule.night_start = MINUTES_PER_DAY; // never night: fine @@ -540,6 +675,22 @@ void test_settings_times_must_be_in_order() TEST_ASSERT_NOT_NULL(validateSettings(s)); } +void test_settings_slow_mode_interval() +{ + Settings s; + for (uint8_t m : SLOW_MODE_MINUTES) + { + s.slow_mode.minutes = m; + TEST_ASSERT_NULL(validateSettings(s)); + } + s.slow_mode.minutes = 7; + TEST_ASSERT_NOT_NULL(validateSettings(s)); + s.slow_mode.minutes = 0; + TEST_ASSERT_NOT_NULL(validateSettings(s)); + TEST_ASSERT_FALSE(Settings().slow_mode.enabled); + TEST_ASSERT_EQUAL(15, Settings().slow_mode.minutes); +} + void test_settings_drive_ranges() { Settings s; @@ -713,23 +864,13 @@ void test_schedule_custom_times() { ScheduleSettings s; s.day_start = 6 * 60 + 30; - s.late_start = 20 * 60; s.night_start = 21 * 60; TEST_ASSERT_TRUE(scheduleFor(6, 29, s) == ScheduleMode::Night); TEST_ASSERT_TRUE(scheduleFor(6, 30, s) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Standby); - TEST_ASSERT_TRUE(scheduleFor(20, 30, s) == ScheduleMode::Clock); + TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Clock); TEST_ASSERT_TRUE(scheduleFor(21, 0, s) == ScheduleMode::Night); } -void test_schedule_without_quarter_hours_stays_on_until_night() -{ - ScheduleSettings s; - s.quarter_hours = false; - TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock); - TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night); -} - // --------------------------------------------------------------------------- // Pages and live mode // --------------------------------------------------------------------------- @@ -783,7 +924,7 @@ void test_settings_change_redraws() run(18, 0); ClockSettings c; c.no_wifi_screen = false; - controller->setSettings(defaults.schedule, c); + controller->setSettings(defaults.schedule, c, defaults.slow_mode); run(18, 0); TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); } @@ -1847,6 +1988,29 @@ void test_selector_take_over_rules() TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once } +void test_selector_rule_over_slow_mode() +{ + PageSchedule s = twoBlocks(); + s.rule_count = 2; + s.rules[0] = rule("Deurbel", TriggerMode::WhileTrue); + s.rules[0].over_slow_mode = true; + s.rules[1] = rule("3D printer", TriggerMode::WhileTrue); + PageSelector sel; + sel.setSchedule(&s); + TEST_ASSERT_FALSE(sel.update(8 * 60, 0).over_slow_mode); + sel.setRuleState(1, true, 0); + PageChoice c = sel.update(8 * 60, 0); + TEST_ASSERT_EQUAL_STRING("3D printer", c.page); + TEST_ASSERT_FALSE(c.over_slow_mode); + sel.setRuleState(0, true, 0); + c = sel.update(8 * 60, 0); + TEST_ASSERT_EQUAL_STRING("Deurbel", c.page); + TEST_ASSERT_TRUE(c.over_slow_mode); + sel.setRuleState(0, false, 0); + TEST_ASSERT_FALSE(sel.update(8 * 60, 0).over_slow_mode); + TEST_ASSERT_FALSE(TriggerRule().over_slow_mode); +} + void test_selector_rule_can_leave_a_manual_pick_alone() { PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie @@ -2400,7 +2564,8 @@ int main() RUN_TEST(test_schedule_night_until_8); RUN_TEST(test_schedule_clock_from_8_to_22); - RUN_TEST(test_schedule_quarter_hours_until_23_30); + RUN_TEST(test_schedule_clock_until_23_30); + RUN_TEST(test_slow_mode_update_minutes); RUN_TEST(test_schedule_night_from_23_31); RUN_TEST(test_timer_expires_once_after_delay); @@ -2432,8 +2597,16 @@ int main() RUN_TEST(test_clock_is_only_sent_when_the_minute_changes); RUN_TEST(test_night_clears_then_powers_off_after_delay); RUN_TEST(test_night_does_not_power_on); - RUN_TEST(test_standby_keeps_the_last_time_and_powers_off); - RUN_TEST(test_quarter_hour_powers_on_again_and_redraws); + RUN_TEST(test_slow_mode_keeps_the_last_time_and_powers_off); + RUN_TEST(test_slow_mode_powers_on_again_and_redraws); + RUN_TEST(test_slow_mode_other_intervals); + RUN_TEST(test_slow_mode_switched_on_keeps_the_image); + RUN_TEST(test_slow_mode_after_boot_updates_at_once); + RUN_TEST(test_slow_mode_page_changes_wait); + RUN_TEST(test_slow_mode_freezes_moving_pages); + RUN_TEST(test_slow_mode_switched_off_updates_at_once); + RUN_TEST(test_slow_mode_power_switch_on_updates_at_once); + RUN_TEST(test_slow_mode_override_by_a_rule); RUN_TEST(test_clock_cancels_pending_power_off); RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly); RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect); @@ -2448,6 +2621,7 @@ int main() RUN_TEST(test_default_settings_are_valid); RUN_TEST(test_settings_times_must_be_in_order); + RUN_TEST(test_settings_slow_mode_interval); RUN_TEST(test_settings_drive_ranges); RUN_TEST(test_effect_time_is_zero_when_instant); RUN_TEST(test_settings_off_page_blank_or_bitmap_name); @@ -2461,7 +2635,6 @@ int main() RUN_TEST(test_bitmap_names); RUN_TEST(test_schedule_disabled_is_always_on); RUN_TEST(test_schedule_custom_times); - RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night); RUN_TEST(test_bitmap_page_is_shown_once); RUN_TEST(test_bitmap_page_redrawn_when_changed); RUN_TEST(test_switching_back_to_the_clock_page); @@ -2514,6 +2687,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_over_slow_mode); 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);