Files
Flipdot-display/src/esp32/app.cpp
T
mangavalkandClaude Opus 5.5 51b8d87e86 Rules can leave a manual pick alone, Clock is an editable page, fix HA attributes
- Rules: "also over a page chosen by hand" (on by default); off, a rule
  waits until the manual pick ends. Schedule store version 3, version 2
  records are read with the option on.
- The Clock page is stored at boot from the built-in one and can be
  edited; Reset (deleting it) brings back the original.
- Home Assistant attributes were never found: ArduinoJson 7.4 takes a
  char array key as a literal of its full array length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:13:23 +02:00

614 lines
16 KiB
C++

#include "app.h"
#include <string.h>
#include <sys/time.h>
#include <time.h>
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/icon_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "logic/page_selector.h"
#include "net/ha_api.h"
#include "system/schedule_store.h"
#include "system/settings_store.h"
static void copyName(char *out, const char *name)
{
strncpy(out, name, PAGE_NAME_SIZE - 1);
out[PAGE_NAME_SIZE - 1] = '\0';
}
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
FlipdotFrames frames(display, pageResources);
StatusScreen status(display, nano);
DisplayController controller(nano, frames);
LoopStats loopStats;
NanoMonitor nanoMonitor(nano, display);
static Settings settings;
static bool restart_pending = false;
static uint32_t restart_at = 0;
// Not on the stack: about 1.7 KB
static DynamicPage loaded_page;
// What is shown when (about 2.7 KB), and which page that is now
static PageSchedule page_schedule;
static PageSelector selector;
static char shown[PAGE_NAME_SIZE] = DEFAULT_PAGE;
static PageChoice shown_choice;
static bool time_known = false;
static void showDynamicPage()
{
frames.setDynamicPage(loaded_page);
haApiWatch(&loaded_page);
controller.setPage(Page::Dynamic);
}
// Load the shown page into the frames: a dynamic page or a bitmap; the Clock page when it is
// gone. instant: the next frame without the refresh effect.
static void applyPage(bool instant = false)
{
if (instant)
frames.showNextInstant();
if (loadPage(shown, loaded_page))
{
showDynamicPage();
return;
}
uint8_t bitmap[BITMAP_BYTES];
if (loadBitmap(shown, bitmap))
{
frames.setPageBitmap(bitmap);
haApiWatch(nullptr);
controller.setPage(Page::Bitmap);
return;
}
Serial.printf("Page \"%s\" not found, showing the clock\n", shown);
builtinPage(DEFAULT_PAGE, loaded_page);
showDynamicPage();
}
// Load the off bitmap into the frames; blank when it is gone
static void applyOffPage()
{
uint8_t bitmap[BITMAP_BYTES];
if (settings.off_page[0] && loadBitmap(settings.off_page, bitmap))
frames.setOffBitmap(bitmap);
else
{
if (settings.off_page[0])
Serial.printf("Off bitmap \"%s\" not found, the display is cleared instead\n", settings.off_page);
frames.setOffBitmap(nullptr);
}
}
static void apply()
{
controller.setSettings(settings.schedule, settings.clock);
controller.setEffectTime(effectTime(settings.drive));
status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots);
frames.setDrive(settings.flip_time, settings.drive);
char user[PLEX_USER_SIZE];
plexUserSlug(settings.plex_user, user, sizeof(user));
haApiSetSettings(settings.home_assistant, user);
haApiWatchRules(&page_schedule); // {plex_user} may have changed
applyOffPage();
applyPage();
}
// Something the current page may use changed: load it again and redraw
static void reloadPage()
{
applyPage();
controller.pageChanged();
}
const Settings &appSettings()
{
return settings;
}
void appLoadSettings()
{
loadSettings(settings);
loadSchedule(page_schedule);
selector.setSchedule(&page_schedule);
selector.setChosen(settings.page);
haApiWatchRules(&page_schedule);
copyName(shown, settings.page); // until the time is known
apply();
}
static bool sameMqtt(const HomeAssistantSettings &a, const HomeAssistantSettings &b)
{
return a.enabled == b.enabled && a.port == b.port && strcmp(a.host, b.host) == 0 &&
strcmp(a.user, b.user) == 0 && strcmp(a.password, b.password) == 0;
}
const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
{
const char *error = validateSettings(new_settings);
if (error)
return error;
if (!pageExists(new_settings.page) && !bitmapExists(new_settings.page))
return "Page not found";
if (new_settings.off_page[0] && !bitmapExists(new_settings.off_page))
return "Off bitmap not found";
restart_needed = !sameMqtt(settings.home_assistant, new_settings.home_assistant);
const DriveSettings &a = settings.drive, &b = new_settings.drive;
bool drive_changed = settings.flip_time != new_settings.flip_time || a.flip_gap != b.flip_gap ||
a.pulses != b.pulses || a.effect != b.effect || a.effect_time != b.effect_time ||
a.effect_small_only != b.effect_small_only ||
settings.refresh.dots != new_settings.refresh.dots;
settings = new_settings;
saveSettings(settings);
selector.setChosen(settings.page);
apply();
if (drive_changed)
status.sendDrive();
return nullptr;
}
// The chosen page, without overriding the schedule
static void setChosen(const char *name)
{
copyName(settings.page, name);
saveSettings(settings);
selector.setChosen(name);
}
const char *appSelectPage(const char *name)
{
if (!pageExists(name) && !bitmapExists(name))
return "Page not found";
setChosen(name);
selector.pick(name);
if (!time_known)
{
copyName(shown, name);
applyPage();
}
return nullptr;
}
const char *appSaveSchedule(const PageSchedule &schedule)
{
const char *error = validateSchedule(schedule);
if (error)
return error;
for (int i = 0; i < schedule.block_count; i++)
for (int j = 0; j < schedule.blocks[i].item_count; j++)
{
const char *page = schedule.blocks[i].items[j].page;
if (!pageExists(page) && !bitmapExists(page))
return "A page of the schedule was not found";
}
for (int i = 0; i < schedule.rule_count; i++)
if (!pageExists(schedule.rules[i].page) && !bitmapExists(schedule.rules[i].page))
return "The page of a rule was not found";
page_schedule = schedule;
saveSchedule(page_schedule);
selector.setSchedule(&page_schedule);
haApiWatchRules(&page_schedule);
return nullptr;
}
const PageSchedule &appSchedule()
{
return page_schedule;
}
void appBackToSchedule()
{
selector.backToSchedule();
}
const char *appShownPage()
{
return shown;
}
void appShowStatus(char *out, size_t size)
{
static const char *const MODES[] = {"while true", "in rotation", "for a while"};
switch (shown_choice.reason)
{
case ShowReason::Trigger:
case ShowReason::Rotation:
if (shown_choice.rule >= 0 && shown_choice.rule < page_schedule.rule_count)
{
const TriggerRule &r = page_schedule.rules[shown_choice.rule];
snprintf(out, size, "rule %d (%s, %s)", shown_choice.rule + 1, r.entity, MODES[(int)r.mode]);
return;
}
break;
case ShowReason::Schedule:
snprintf(out, size, "schedule");
return;
default:
break;
}
int until = selector.pickedUntil();
if (until >= 0)
snprintf(out, size, "chosen, until %02d:%02d", until / 60, until % 60);
else
snprintf(out, size, selector.scheduleActive() ? "chosen" : "chosen (no schedule)");
}
// Which page now: the rules (from Home Assistant), the schedule, the chosen page
void appPageLoop(bool time_valid, const LocalTime &time)
{
uint32_t now = millis();
for (int i = 0; i < page_schedule.rule_count; i++)
{
const TriggerRule &r = page_schedule.rules[i];
selector.setRuleState(i, ruleMatches(r, haApiValue(r.entity, r.attribute)), now);
}
time_known = time_valid;
if (!time_valid)
return;
shown_choice = selector.update(time.hour * 60 + time.minute, now);
if (!shown_choice.changed && sameName(shown, shown_choice.page))
return;
copyName(shown, shown_choice.page);
applyPage(shown_choice.instant);
}
const char *appSelectOffPage(const char *name)
{
if (name[0] && !bitmapExists(name))
return "Bitmap not found";
copyName(settings.off_page, name);
saveSettings(settings);
applyOffPage();
controller.pageChanged();
return nullptr;
}
const char *appSaveBitmap(const char *name, const uint8_t *bitmap)
{
if (pageExists(name))
return "A page has this name";
if (iconExists(name))
return "An icon has this name";
const char *error = saveBitmap(name, bitmap);
if (error)
return error;
if (sameName(settings.off_page, name))
applyOffPage();
reloadPage(); // the page or its background
return nullptr;
}
// "Used by page X ..." (the returned text stays valid until the next call)
static const char *usedBy(const String &page)
{
static char message[80];
snprintf(message, sizeof(message), "Used by page \"%s\": change that page first", page.c_str());
return message;
}
static const char *USED_BY_SCHEDULE = "Used by the schedule or a rule: change that first";
const char *appDeleteBitmap(const char *name)
{
if (scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE;
String user = pageUsingImage(name);
if (user.length())
return usedBy(user);
const char *error = deleteBitmap(name);
if (error)
return error;
if (sameName(settings.page, name))
setChosen(DEFAULT_PAGE);
if (sameName(settings.off_page, name))
appSelectOffPage(OFF_PAGE_BLANK);
return nullptr;
}
const char *appSavePage(const char *name, const DynamicPage &page)
{
if (bitmapExists(name))
return "A bitmap has this name";
if (iconExists(name))
return "An icon has this name";
for (int i = 0; i < page.slot_count; i++)
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap) && !iconExists(page.slots[i].bitmap))
return "Background bitmap or icon not found";
for (int i = 0; i < page.element_count; i++)
if (!fontExists(page.elements[i].font))
return "Font not found";
const char *error = savePage(name, page);
if (error)
return error;
if (sameName(shown, name))
reloadPage();
return nullptr;
}
const char *appDeletePage(const char *name)
{
// A stored copy of a built-in page: deleting it resets the page, which stays
bool resets = builtinPageName(name) != nullptr;
if (!resets && scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE;
const char *error = deletePage(name);
if (error)
return error;
if (resets)
{
if (sameName(shown, name))
reloadPage();
return nullptr;
}
if (sameName(settings.page, name))
setChosen(DEFAULT_PAGE);
return nullptr;
}
const char *appSaveIcon(const char *name, const Icon &icon)
{
if (pageExists(name))
return "A page has this name";
if (bitmapExists(name))
return "A bitmap has this name";
const char *error = saveIcon(name, icon);
if (error)
return error;
reloadPage(); // the page may use it
return nullptr;
}
const char *appDeleteIcon(const char *name)
{
String user = pageUsingImage(name);
if (user.length())
return usedBy(user);
const char *error = deleteIcon(name);
if (error)
return error;
reloadPage(); // an image by state may have shown it
return nullptr;
}
// Why the bitmap can't become an icon (it is shown on its own), or nullptr
static const char *shownOnItsOwn(const char *name)
{
if (builtinBitmap(name))
return "Built-in bitmaps can't become icons";
if (scheduleUses(page_schedule, name))
return "Shown by the schedule or a rule: change that first";
if (sameName(settings.page, name))
return "It is the chosen page: choose another one first";
if (sameName(settings.off_page, name))
return "It is the off image: choose another one first";
return nullptr;
}
// Without redrawing: the pages look the same
static const char *moveToIcon(const char *name)
{
uint8_t bitmap[BITMAP_BYTES];
if (!loadBitmap(name, bitmap))
return "Bitmap not found";
Icon icon;
iconFromBitmap(bitmap, icon);
const char *error = saveIcon(name, icon);
if (error)
return error;
error = deleteBitmap(name);
if (error)
deleteIcon(name); // only one of them
return error;
}
const char *appBitmapToIcon(const char *name)
{
const char *error = shownOnItsOwn(name);
if (error)
return error;
error = moveToIcon(name);
if (error)
return error;
reloadPage();
return nullptr;
}
const char *appIconToBitmap(const char *name)
{
Icon icon;
if (!loadIcon(name, icon))
return "Icon not found";
uint8_t bitmap[BITMAP_BYTES];
iconToBitmap(icon, bitmap);
const char *error = saveBitmap(name, bitmap);
if (error)
return error;
error = deleteIcon(name);
if (error)
{
deleteBitmap(name); // only one of them
return error;
}
reloadPage();
return nullptr;
}
// The one-time move, in steps over many loop() passes: LittleFS is slow (listing a
// directory of 40 files takes about a second), and loop() must never get near the
// watchdog. Each pass works for about MIGRATION_STEP_MS.
enum class Migration
{
Start,
Pages, // which candidates the pages use (one page after the other)
Move,
Done,
};
static const uint32_t MIGRATION_STEP_MS = 1000;
static Migration migration = Migration::Start;
static std::vector<String> migration_bitmaps; // uploaded bitmaps not shown on their own
static std::vector<bool> migration_used;
static std::vector<String> migration_pages;
static size_t migration_next = 0;
static int migration_moved = 0;
static uint32_t migration_started = 0;
void appMigrateIcons()
{
if (migration == Migration::Done)
return;
uint32_t pass = millis();
auto time_left = [pass]() { return millis() - pass < MIGRATION_STEP_MS; };
if (migration == Migration::Start)
{
if (iconMigrationDone())
{
migration = Migration::Done;
return;
}
migration_started = pass;
for (const BitmapInfo &b : listBitmaps())
if (!b.builtin && !shownOnItsOwn(b.name.c_str()))
migration_bitmaps.push_back(b.name);
migration_used.assign(migration_bitmaps.size(), false);
for (const PageInfo &p : listPages())
migration_pages.push_back(p.name);
migration_next = 0;
migration = Migration::Pages;
return;
}
if (migration == Migration::Pages)
{
DynamicPage *page = new DynamicPage;
while (migration_next < migration_pages.size() && time_left())
if (loadPage(migration_pages[migration_next++].c_str(), *page))
for (size_t i = 0; i < migration_bitmaps.size(); i++)
if (pageUsesImage(*page, migration_bitmaps[i].c_str(), true))
migration_used[i] = true;
delete page;
if (migration_next >= migration_pages.size())
{
migration_next = 0;
migration = Migration::Move;
}
return;
}
while (migration_next < migration_bitmaps.size() && time_left())
{
size_t i = migration_next++;
const char *name = migration_bitmaps[i].c_str();
// Checked again: the web UI may have changed something in the meantime
if (!migration_used[i] || shownOnItsOwn(name))
continue;
const char *error = moveToIcon(name);
if (error)
Serial.printf("Bitmap \"%s\" stays a bitmap: %s\n", name, error);
else
migration_moved++;
}
if (migration_next < migration_bitmaps.size())
return;
setIconMigrationDone();
Serial.printf("%d bitmaps that only pages use became icons (%lu ms)\n", migration_moved,
(unsigned long)(millis() - migration_started));
migration_bitmaps.clear();
migration_used.clear();
migration_pages.clear();
migration = Migration::Done;
if (migration_moved)
reloadPage();
}
const char *appSaveFont(const char *name, const uint8_t *data, size_t size)
{
const char *error = saveFont(name, data, size);
if (error)
return error;
controller.pageChanged(); // the page may use it
return nullptr;
}
const char *appDeleteFont(const char *name)
{
String user = pageUsingFont(name);
if (user.length())
return usedBy(user);
return deleteFont(name);
}
void appStopwatch(StopwatchAction action)
{
if (action == StopwatchAction::Start)
stopwatch.start(millis());
else if (action == StopwatchAction::Stop)
stopwatch.stop(millis());
else
stopwatch.reset();
controller.pageChanged();
}
void appShowLive(const uint8_t *bitmap)
{
frames.setLiveBitmap(bitmap);
controller.showLive(millis());
}
void appStopLive()
{
controller.stopLive();
}
bool appLocalTime(LocalTime &time)
{
struct tm now = {};
if (!getLocalTime(&now, 0))
return false;
time.year = now.tm_year + 1900;
time.month = now.tm_mon + 1;
time.day = now.tm_mday;
time.weekday = now.tm_wday;
time.hour = now.tm_hour;
time.minute = now.tm_min;
time.second = now.tm_sec;
struct timeval tv;
gettimeofday(&tv, nullptr);
time.millisecond = tv.tv_usec / 1000;
// Local time - UTC (summer time included), for Home Assistant's UTC timestamps
struct tm utc = {};
time_t seconds = tv.tv_sec;
gmtime_r(&seconds, &utc);
int day_diff = now.tm_yday - utc.tm_yday;
if (day_diff > 1) // around new year
day_diff = -1;
else if (day_diff < -1)
day_diff = 1;
time.utc_offset = day_diff * 24 * 60 + (now.tm_hour - utc.tm_hour) * 60 + (now.tm_min - utc.tm_min);
return true;
}
void appRestartLater(uint32_t delay_ms)
{
restart_pending = true;
restart_at = millis() + delay_ms;
}
void appLoop()
{
if (restart_pending && (int32_t)(millis() - restart_at) >= 0)
{
Serial.println("Restarting for the new settings");
ESP.restart();
}
}