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
+726 -126

No files matched your search

+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
+6 -3
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;
return ScheduleMode::Clock;
}
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);
+86 -31
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)
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();
}
+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;