Slow mode replaces the late-evening quarter hours

Slow mode (Home Assistant switch "FlipDot Display Slow mode", dropdown
"FlipDot Display Slow mode interval", web UI Settings) makes the display
change only every 1, 2, 5, 10, 15, 20, 30 or 60 minutes, on the clock, and
powers it off in between: less noise. Nothing else is drawn meanwhile, also
no page changes. It updates at once after boot, the night, the Power switch
and live mode, and when it is switched off. Rules can override it ("show at
once in slow mode"): while their page is shown the display works normally.

Settings layout 7 and schedule layout 4 (older records are converted; they
are stored again once the firmware is marked good, so a rollback still
finds the old ones). Pages in older formats are rewritten in the current
one, in time-boxed steps. /api/status reports old_data_left.

Version 2.15.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-17 21:16:41 +02:00
1 parent a904615c51
commit 968492ca35
39 files changed
+724 -124

No files matched your search

+7 -1
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 156 KiB

+4
View File
@@ -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.
+6 -3
View File
@@ -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
+6 -3
View File
@@ -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
+84 -3
View File
@@ -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<String> 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);
+11
View File
@@ -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);
+1 -1
View File
@@ -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};
+17
View File
@@ -536,6 +536,23 @@ bool loadPage(const char *name, DynamicPage &page)
return ok || builtinPage(name, page);
}
std::vector<String> 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))
+4
View File
@@ -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<String> 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.
+55 -6
View File
@@ -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())
+14 -1
View File
@@ -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);
+1
View File
@@ -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
+1
View File
@@ -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);
+1
View File
@@ -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
+5 -2
View File
@@ -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;
}
bool slowModeUpdate(int hour, int minute, int interval)
{
return interval <= 1 || (hour * 60 + minute) % interval == 0;
}
+8 -4
View File
@@ -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);
+13 -3
View File
@@ -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)
+13 -2
View File
@@ -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)
};
+3
View File
@@ -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();
+54
View File
@@ -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<String> 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());
+2
View File
@@ -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.
+93 -17
View File
@@ -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)
+3 -2
View File
@@ -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);
+85 -30
View File
@@ -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 <typename T> 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)
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();
}
+3 -2
View File
@@ -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);
+2
View File
@@ -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<JsonArray>();
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<JsonArrayConst>())
{
+12 -6
View File
@@ -150,10 +150,12 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
<label>Display on from</label><input type="time" id="s_day">
<label>Late evening from</label><input type="time" id="s_late">
<label>Quarter hours</label><label><input type="checkbox" id="s_q"> late evening: only at :00 :15 :30 :45</label>
<label>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
</div></div>
<div class="card"><h2>Slow mode</h2><div class="form">
<label>Slow mode</label><label><input type="checkbox" id="s_slow"> on (also the Home Assistant switch)</label>
<label>Update every</label><select id="s_slowmin"><option value="1">minute</option><option value="2">2 minutes</option><option value="5">5 minutes</option><option value="10">10 minutes</option><option value="15">15 minutes</option><option value="20">20 minutes</option><option value="30">30 minutes</option><option value="60">hour</option></select>
</div><p class="hint">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".</p></div>
<div class="card"><h2>Without WiFi</h2><div class="form">
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" instead of a dynamic page when WiFi is down</label>
</div><p class="hint">The backgrounds per time of day (like the yawning Pikachu) are set per page in the Pages tab.</p></div>
@@ -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};
+10 -6
View File
@@ -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<JsonObject>();
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<JsonObject>();
slow["enabled"] = s.slow_mode.enabled;
slow["minutes"] = s.slow_mode.minutes;
JsonObject clock = o["clock"].to<JsonObject>();
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<int>())
s.slow_mode.minutes = constrain(body["slow_mode"]["minutes"].as<int>(), 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;
+206 -32
View File
@@ -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);