diff --git a/CLAUDE.md b/CLAUDE.md index 8e7e1e5..3932cf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,16 @@ See README.md for the overview. `VERSION` there (old records then fall back to defaults). - Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/.bin`, 209 bytes). Names are restricted (`validBitmapName`), which also makes them safe in paths and JSON. +- Icons (`logic/icon_format.h`, `display/icon_store`, `/icons/.bin`: width, height, + rows of (w+7)/8 bytes) are small images that pages use (Image elements, backgrounds at + the top left); they are never shown on their own and are not in the page lists or HA + dropdowns. Bitmaps, icons and pages share one namespace. `PageResources::background()` + tries bitmaps, then icons (expanded at 0,0), so the renderer doesn't know the difference. + `appMigrateIcons()` ran once (marker `/icons/.migrated`) after 2.10.0 was marked good. +- LittleFS is slow: listing a directory (`listBitmaps()`, `listPages()`, `storedNames()`) + takes about a second with 40 files. Never list per item in a loop. The first icon + migration did that, hit the 30 s watchdog and crash-looped into the recovery app; work + that touches many files runs in time-boxed steps over several `loop()` passes. - The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`). - Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage` diff --git a/Pictures/web-ui/bitmaps.png b/Pictures/web-ui/bitmaps.png index 327f15e..dc87fe3 100644 Binary files a/Pictures/web-ui/bitmaps.png and b/Pictures/web-ui/bitmaps.png differ diff --git a/Pictures/web-ui/draw.png b/Pictures/web-ui/draw.png index 022909a..7554dfc 100644 Binary files a/Pictures/web-ui/draw.png and b/Pictures/web-ui/draw.png differ diff --git a/Pictures/web-ui/home-assistant.png b/Pictures/web-ui/home-assistant.png index 3af3444..72e5346 100644 Binary files a/Pictures/web-ui/home-assistant.png and b/Pictures/web-ui/home-assistant.png differ diff --git a/Pictures/web-ui/icons.png b/Pictures/web-ui/icons.png new file mode 100644 index 0000000..c247e4d Binary files /dev/null and b/Pictures/web-ui/icons.png differ diff --git a/Pictures/web-ui/pages.png b/Pictures/web-ui/pages.png index 108b77a..83724ba 100644 Binary files a/Pictures/web-ui/pages.png and b/Pictures/web-ui/pages.png differ diff --git a/Pictures/web-ui/schedule.png b/Pictures/web-ui/schedule.png index ccd4264..b180adf 100644 Binary files a/Pictures/web-ui/schedule.png and b/Pictures/web-ui/schedule.png differ diff --git a/Pictures/web-ui/settings.png b/Pictures/web-ui/settings.png index e5c8421..484428c 100644 Binary files a/Pictures/web-ui/settings.png and b/Pictures/web-ui/settings.png differ diff --git a/Pictures/web-ui/show.png b/Pictures/web-ui/show.png index 24418be..a05af70 100644 Binary files a/Pictures/web-ui/show.png and b/Pictures/web-ui/show.png differ diff --git a/Pictures/web-ui/stopwatch.png b/Pictures/web-ui/stopwatch.png index 1064d2c..2431dbc 100644 Binary files a/Pictures/web-ui/stopwatch.png and b/Pictures/web-ui/stopwatch.png differ diff --git a/Pictures/web-ui/system.png b/Pictures/web-ui/system.png index 581fcd9..b4942e8 100644 Binary files a/Pictures/web-ui/system.png and b/Pictures/web-ui/system.png differ diff --git a/docs/home-assistant.md b/docs/home-assistant.md index b72b795..3cbfdbc 100644 --- a/docs/home-assistant.md +++ b/docs/home-assistant.md @@ -57,7 +57,7 @@ Assistant's Plex and Tautulli integrations, no extra firmware: - `sensor.mangavalkserver` (Plex): how many are watching. - `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon - (bitmaps `plex playing`, `plex paused`, `plex buffering`). + (icons `plex playing`, `plex paused`, `plex buffering`). - `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user): the title and the progress bar. Home Assistant creates these disabled: enable them on the Tautulli user's device in Home Assistant. diff --git a/docs/web-ui.md b/docs/web-ui.md index 60c2a22..9a93825 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -13,7 +13,7 @@ headless mode). ## Show Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: -Pikachu with the time) or any bitmap, and what is left on the dots when the display +Pikachu with the time) or any bitmap (not the [icons](#icons)), and what is left on the dots when the display switches off. With a [schedule](#schedule), a page chosen here (or with the Home Assistant "Mode" dropdown) is shown until the next block starts; "Back to schedule" ends it earlier. Bitmaps that pages or rules use are in their own subsection. @@ -22,8 +22,8 @@ Bitmaps that pages or rules use are in their own subsection. ## Pages -Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning -Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it +Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu +from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it is exactly what the display shows; drag the elements on it, or select one and use the arrow keys. Built-in pages can be copied, not changed. See the [pictures of the pages](pages.md). @@ -35,7 +35,7 @@ arrow keys. Built-in pages can be copied, not changed. See the [pictures of the | Text | Up to 32 characters | | Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed | | Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant | -| Image | A bitmap at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the bitmap `weer cloudy`) | +| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) | | Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame | Every element has a position, alignment, font and overlay (on top, box that clears the @@ -71,16 +71,33 @@ Start, stop and reset the stopwatch that pages can show (also in Home Assistant) All bitmaps with a preview: show one as the page, send it live, open it in the editor, export or delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be -deleted, nor can a bitmap that a page, the schedule or a rule uses. +deleted, nor can a bitmap that a page, the schedule or a rule uses. "Make icon" turns a +bitmap that is only part of pages into an [icon](#icons). ![Bitmaps](../Pictures/web-ui/bitmaps.png) +## Icons + +Small images (1 x 1 up to 84 x 19 dots, each its own size) for dynamic pages: an Image +element, or a background (drawn at the top left). Icons are never shown on their own, so +they are not in the page lists (Show, Schedule, rules, Home Assistant "Mode"). Each icon +shows its size and the pages that use it. The editor: draw dots, change the size (the dots +stay at the top left), crop to the dots, clear, invert, load an image (scaled to the icon +size), show it on the display. "Make bitmap" turns an icon into a full screen bitmap. + +Firmware 2.10.0 made icons of all uploaded bitmaps that only pages used (backgrounds and +images, not shown by the schedule, a rule, the chosen page or the off image), once, after +the new firmware was marked as good. They keep their names and land on the same dots, so +the pages didn't change. + +![Icons](../Pictures/web-ui/icons.png) + ## Draw / Live Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears -on the dots. Save it as a bitmap with a name. +on the dots. Save it as a bitmap with a name, or as an icon (only the area with dots). ![Draw / Live](../Pictures/web-ui/draw.png) @@ -115,11 +132,12 @@ few seconds). `loop()` never sleeps, so a CPU percentage would always say 100 %. how often the image was repaired, Nano restarts and `ERR` replies (with the last one). **Memory:** free heap now and the lowest since boot. -**Backup:** export everything (settings, the schedule and rules, your bitmaps, pages and -fonts; the MQTT password and HA token only when ticked) to one JSON file, and import a -file: choose which items and which parts of the settings. Imported items are added (a -taken name gets a number) unless you give them an existing name. Pages and bitmaps also -have their own Export button, e.g. to share one page (with the bitmaps and fonts it uses). +**Backup:** export everything (settings, the schedule and rules, your bitmaps, icons, +pages and fonts; the MQTT password and HA token only when ticked) to one JSON file, and +import a file: choose which items and which parts of the settings. Imported items are +added (a taken name gets a number) unless you give them an existing name. Pages, bitmaps +and icons also have their own Export button, e.g. to share one page (with the bitmaps, +icons and fonts it uses). ![System](../Pictures/web-ui/system.png) @@ -129,8 +147,8 @@ have their own Export button, e.g. to share one page (with the bitmaps and fonts ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power switch still wins: when it is off, nothing is shown. -**Bitmaps, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB, room for well over -a thousand bitmaps, 50 pages, 20 fonts). Pages and bitmaps share their names. They need +**Bitmaps, icons, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB; up to 200 +bitmaps, 200 icons, 50 pages, 20 fonts). Pages, bitmaps and icons share their names. They need the new flash layout from the [one-time installation](recovery.md#one-time-installation-usb); before that, only the built-in ones are available. diff --git a/src/esp32/README.md b/src/esp32/README.md index 61fb332..1024d50 100644 --- a/src/esp32/README.md +++ b/src/esp32/README.md @@ -20,6 +20,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md). | `logic/settings.*` | The settings of the web UI with their defaults and checks | | `logic/schedule.*` | When the display is on (night, clock, standby), from the settings | | `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts | +| `logic/icon_format.*` | Icons: small bitmaps of their own size, to and from full screen bitmaps | | `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) | | `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) | | `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing | @@ -40,6 +41,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md). | **`display/`** | **What is drawn** | | `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) | | `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS | +| `display/icon_store.*` | Icons (small images for pages) on LittleFS | | `display/page_store.*` | Built-in and stored dynamic pages (`/pages/.pg`) | | `display/font_store.*` | Built-in and uploaded fonts (`/fonts/.fnt`) | | `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values | diff --git a/src/esp32/app.cpp b/src/esp32/app.cpp index eb91b03..8fd768c 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -7,6 +7,7 @@ #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" @@ -274,6 +275,8 @@ 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; @@ -297,7 +300,7 @@ const char *appDeleteBitmap(const char *name) { if (scheduleUses(page_schedule, name)) return USED_BY_SCHEDULE; - String user = pageUsingBitmap(name); + String user = pageUsingImage(name); if (user.length()) return usedBy(user); const char *error = deleteBitmap(name); @@ -314,9 +317,11 @@ 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)) - return "Background bitmap not found"; + 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"; @@ -340,6 +345,183 @@ const char *appDeletePage(const char *name) 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 migration_bitmaps; // uploaded bitmaps not shown on their own +static std::vector migration_used; +static std::vector 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); diff --git a/src/esp32/app.h b/src/esp32/app.h index 744e539..191926a 100644 --- a/src/esp32/app.h +++ b/src/esp32/app.h @@ -7,6 +7,7 @@ #include "display/frames.h" #include "display/status_screen.h" #include "logic/display_controller.h" +#include "logic/icon_format.h" #include "logic/local_time.h" #include "logic/loop_stats.h" #include "logic/page.h" @@ -48,11 +49,20 @@ void appPageLoop(bool time_valid, const LocalTime &time); // Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name const char *appSelectOffPage(const char *name); -// Bitmaps, dynamic pages and fonts: stored, and the display redrawn when it shows them. -// Bitmaps and pages share one namespace; what a page uses can't be deleted. All return -// nullptr or an error message. +// Bitmaps, icons, dynamic pages and fonts: stored, and the display redrawn when it shows +// them. Bitmaps, icons and pages share one namespace; what a page uses can't be deleted. +// All return nullptr or an error message. const char *appSaveBitmap(const char *name, const uint8_t *bitmap); const char *appDeleteBitmap(const char *name); +const char *appSaveIcon(const char *name, const Icon &icon); +const char *appDeleteIcon(const char *name); +// A bitmap becomes an icon (cut to its dots from the top left, so pages look the same) and +// the other way around; not for bitmaps that are shown on their own +const char *appBitmapToIcon(const char *name); +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(); const char *appSavePage(const char *name, const DynamicPage &page); const char *appDeletePage(const char *name); const char *appSaveFont(const char *name, const uint8_t *data, size_t size); diff --git a/src/esp32/config.h b/src/esp32/config.h index 780e089..b958e1c 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi const uint32_t WIFI_CONNECT_TIMEOUT = 30000; const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; -#define FIRMWARE_VERSION "2.9.0" +#define FIRMWARE_VERSION "2.10.0" // Home Assistant device id (do not change: Home Assistant's entities are linked to it) const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28}; diff --git a/src/esp32/display/bitmap_store.cpp b/src/esp32/display/bitmap_store.cpp index d668245..e2441b1 100644 --- a/src/esp32/display/bitmap_store.cpp +++ b/src/esp32/display/bitmap_store.cpp @@ -51,7 +51,7 @@ static String path(const char *name) bool bitmapStoreBegin() { mounted = LittleFS.begin(true); - for (const char *dir : {DIR, "/pages", "/fonts"}) + for (const char *dir : {DIR, "/pages", "/fonts", "/icons"}) if (mounted && !LittleFS.exists(dir)) LittleFS.mkdir(dir); Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)"); @@ -108,6 +108,11 @@ uint32_t bitmapListRevision() return revision; } +bool builtinBitmap(const char *name) +{ + return builtin(name) != nullptr; +} + bool bitmapExists(const char *name) { if (builtin(name)) diff --git a/src/esp32/display/bitmap_store.h b/src/esp32/display/bitmap_store.h index c3eed2a..553d9f6 100644 --- a/src/esp32/display/bitmap_store.h +++ b/src/esp32/display/bitmap_store.h @@ -31,6 +31,8 @@ std::vector listBitmaps(); // Changes each time a bitmap is saved or deleted uint32_t bitmapListRevision(); bool bitmapExists(const char *name); +// Without reading the storage +bool builtinBitmap(const char *name); bool loadBitmap(const char *name, uint8_t *bitmap); // Return nullptr on success, otherwise a message for the user diff --git a/src/esp32/display/icon_store.cpp b/src/esp32/display/icon_store.cpp new file mode 100644 index 0000000..184580a --- /dev/null +++ b/src/esp32/display/icon_store.cpp @@ -0,0 +1,165 @@ +#include "display/icon_store.h" + +#include +#include +#include +#include + +#include "display/bitmap_store.h" + +static const char *DIR = "/icons"; +// Written when the bitmaps were moved (not a valid name, so never listed) +static const char *MIGRATED = "/icons/.migrated"; + +static uint32_t revision = 0; +// Counted once (listing the directory is slow), then kept up to date; -1: not yet +static int count = -1; + +// Like the bitmaps (display/bitmap_store.cpp): images try a name per state, and pages are +// drawn for every frame and preview, so icons and missing names are kept in RAM +struct CachedIcon +{ + bool exists; + Icon icon; +}; +static std::map cache; +static const size_t CACHE_MAX = 64; + +static String path(const char *name) +{ + return String(DIR) + "/" + name + ".bin"; +} + +static bool ensureDir() +{ + if (!bitmapStorageAvailable()) + return false; + return LittleFS.exists(DIR) || LittleFS.mkdir(DIR); +} + +std::vector listIcons() +{ + std::vector names = storedNames(DIR, ".bin"); + std::sort(names.begin(), names.end(), + [](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; }); + return names; +} + +uint32_t iconListRevision() +{ + return revision; +} + +bool iconExists(const char *name) +{ + Icon icon; + return loadIcon(name, icon); // through the cache +} + +bool loadIcon(const char *name, Icon &icon) +{ + if (!bitmapStorageAvailable() || !validBitmapName(name)) + return false; + auto cached = cache.find(name); + if (cached != cache.end()) + { + if (cached->second.exists) + icon = cached->second.icon; + return cached->second.exists; + } + + // Checked first: open() logs an error for a missing file + bool ok = false; + Icon loaded; + if (LittleFS.exists(path(name))) + { + File file = LittleFS.open(path(name), "r"); + if (file) + { + uint8_t size[2]; + ok = file.read(size, 2) == 2 && validIconSize(size[0], size[1]); + if (ok) + { + loaded.width = size[0]; + loaded.height = size[1]; + size_t bytes = iconBytes(loaded.width, loaded.height); + ok = file.read(loaded.data, bytes) == bytes; + } + file.close(); + } + } + if (cache.size() >= CACHE_MAX) + cache.clear(); + CachedIcon &entry = cache[name]; + entry.exists = ok; + if (ok) + { + entry.icon = loaded; + icon = loaded; + } + return ok; +} + +const char *saveIcon(const char *name, const Icon &icon) +{ + if (!validBitmapName(name)) + return "Invalid name: use 1-24 letters, digits, spaces, - or _"; + if (!validIconSize(icon.width, icon.height)) + return "Icons are 1 x 1 to 84 x 19 dots"; + if (!ensureDir()) + return "No storage (install the new flash layout over USB, see README)"; + bool is_new = !LittleFS.exists(path(name)); + if (count < 0) + count = storedNames(DIR, ".bin").size(); + if (is_new && count >= MAX_ICONS) + return "Too many icons, delete some first"; + + File file = LittleFS.open(path(name), "w"); + if (!file) + return "Could not write the file"; + uint8_t size[2] = {icon.width, icon.height}; + size_t bytes = iconBytes(icon.width, icon.height); + bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes; + file.close(); + revision++; + cache.clear(); + if (!ok) + { + LittleFS.remove(path(name)); // not half an icon + count = -1; + return "Storage full"; + } + if (is_new) + count++; + return nullptr; +} + +const char *deleteIcon(const char *name) +{ + if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name))) + return "Icon not found"; + if (!LittleFS.remove(path(name))) + return "Could not delete the file"; + if (count > 0) + count--; + revision++; + cache.clear(); + return nullptr; +} + +bool iconMigrationDone() +{ + return !bitmapStorageAvailable() || LittleFS.exists(MIGRATED); +} + +void setIconMigrationDone() +{ + if (!ensureDir()) + return; + File file = LittleFS.open(MIGRATED, "w"); + if (file) + { + file.print("1"); + file.close(); + } +} diff --git a/src/esp32/display/icon_store.h b/src/esp32/display/icon_store.h new file mode 100644 index 0000000..1e8e12f --- /dev/null +++ b/src/esp32/display/icon_store.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include "logic/icon_format.h" + +// Icons (logic/icon_format.h): small images that pages use as images or backgrounds, never +// shown on their own. Stored on LittleFS (mounted by display/bitmap_store) as +// /icons/.bin: width, height, then the rows. Icons, bitmaps and pages share one +// namespace: app.cpp checks that a new name is not taken. + +const int MAX_ICONS = 200; + +// Sorted without case +std::vector listIcons(); +// Changes each time an icon is saved or deleted +uint32_t iconListRevision(); +bool iconExists(const char *name); +bool loadIcon(const char *name, Icon &icon); + +// Return nullptr on success, otherwise a message for the user +const char *saveIcon(const char *name, const Icon &icon); +const char *deleteIcon(const char *name); + +// The one-time move of the bitmaps that only pages use (app.cpp) is done +bool iconMigrationDone(); +void setIconMigrationDone(); diff --git a/src/esp32/display/page_resources.cpp b/src/esp32/display/page_resources.cpp index 08c886b..9f4d106 100644 --- a/src/esp32/display/page_resources.cpp +++ b/src/esp32/display/page_resources.cpp @@ -4,6 +4,7 @@ #include "display/bitmap_store.h" #include "display/font_store.h" +#include "display/icon_store.h" #include "net/ha_api.h" Stopwatch stopwatch; @@ -11,7 +12,13 @@ StoredPageResources pageResources(stopwatch); bool StoredPageResources::background(const char *name, uint8_t *bitmap) { - return loadBitmap(name, bitmap); + if (loadBitmap(name, bitmap)) + return true; + Icon icon; + if (!loadIcon(name, icon)) + return false; + iconToBitmap(icon, bitmap); // at the top left: images are drawn from their x, y + return true; } Font StoredPageResources::font(const char *name) diff --git a/src/esp32/display/page_resources.h b/src/esp32/display/page_resources.h index b1921aa..e817453 100644 --- a/src/esp32/display/page_resources.h +++ b/src/esp32/display/page_resources.h @@ -3,7 +3,7 @@ #include "logic/page_render.h" #include "logic/stopwatch.h" -// Bitmaps from display/bitmap_store, fonts from display/font_store, Home Assistant values +// Bitmaps and icons from display/bitmap_store and display/icon_store, fonts from display/font_store, Home Assistant values // from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the // web UI class StoredPageResources : public PageResources diff --git a/src/esp32/display/page_store.cpp b/src/esp32/display/page_store.cpp index 8ebf5b8..95c0089 100644 --- a/src/esp32/display/page_store.cpp +++ b/src/esp32/display/page_store.cpp @@ -458,14 +458,9 @@ template static String findPage(F uses) return found; } -String pageUsingBitmap(const char *bitmap) +String pageUsingImage(const char *name) { - return findPage([bitmap](const DynamicPage &page) { - for (int i = 0; i < page.slot_count; i++) - if (sameName(page.slots[i].bitmap, bitmap)) - return true; - return false; - }); + return findPage([name](const DynamicPage &page) { return pageUsesImage(page, name); }); } String pageUsingFont(const char *font) diff --git a/src/esp32/display/page_store.h b/src/esp32/display/page_store.h index 74e35dd..2b112cf 100644 --- a/src/esp32/display/page_store.h +++ b/src/esp32/display/page_store.h @@ -30,6 +30,7 @@ bool loadPage(const char *name, DynamicPage &page); const char *savePage(const char *name, const DynamicPage &page); const char *deletePage(const char *name); -// The first stored page that uses the bitmap (as background) or the font; empty if none -String pageUsingBitmap(const char *bitmap); +// The first stored page that uses the bitmap or icon (logic/page.h pageUsesImage) or the +// font; empty if none +String pageUsingImage(const char *name); String pageUsingFont(const char *font); diff --git a/src/esp32/logic/icon_format.cpp b/src/esp32/logic/icon_format.cpp new file mode 100644 index 0000000..8fd158e --- /dev/null +++ b/src/esp32/logic/icon_format.cpp @@ -0,0 +1,103 @@ +#include "logic/icon_format.h" + +#include + +bool validIconSize(int width, int height) +{ + return width >= 1 && height >= 1 && width <= ICON_MAX_WIDTH && height <= ICON_MAX_HEIGHT; +} + +bool iconPixel(const Icon &icon, int x, int y) +{ + if (x < 0 || y < 0 || x >= icon.width || y >= icon.height) + return false; + return icon.data[y * iconRowBytes(icon.width) + x / 8] & (0x80 >> (x & 7)); +} + +void setIconPixel(Icon &icon, int x, int y, bool on) +{ + if (x < 0 || y < 0 || x >= icon.width || y >= icon.height) + return; + uint8_t &byte = icon.data[y * iconRowBytes(icon.width) + x / 8]; + if (on) + byte |= 0x80 >> (x & 7); + else + byte &= ~(0x80 >> (x & 7)); +} + +static int hexValue(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +bool iconFromHex(int width, int height, const char *hex, Icon &icon) +{ + if (!validIconSize(width, height) || hex == nullptr) + return false; + int bytes = iconBytes(width, height); + if (strlen(hex) != (size_t)bytes * 2) + return false; + Icon result; + result.width = width; + result.height = height; + for (int i = 0; i < bytes; i++) + { + int high = hexValue(hex[2 * i]); + int low = hexValue(hex[2 * i + 1]); + if (high < 0 || low < 0) + return false; + result.data[i] = (high << 4) | low; + } + // The unused bits at the end of each row stay off + for (int y = 0; y < height; y++) + for (int x = width; x < iconRowBytes(width) * 8; x++) + result.data[y * iconRowBytes(width) + x / 8] &= ~(0x80 >> (x & 7)); + icon = result; + return true; +} + +void iconToHex(const Icon &icon, char *out) +{ + static const char digits[] = "0123456789abcdef"; + int bytes = iconBytes(icon.width, icon.height); + for (int i = 0; i < bytes; i++) + { + out[2 * i] = digits[icon.data[i] >> 4]; + out[2 * i + 1] = digits[icon.data[i] & 0x0F]; + } + out[bytes * 2] = '\0'; +} + +void iconToBitmap(const Icon &icon, uint8_t *bitmap) +{ + memset(bitmap, 0, BITMAP_BYTES); + for (int y = 0; y < icon.height; y++) + for (int x = 0; x < icon.width; x++) + if (iconPixel(icon, x, y)) + setBitmapPixel(bitmap, x, y, true); +} + +void iconFromBitmap(const uint8_t *bitmap, Icon &icon) +{ + int right = 0, bottom = 0; + for (int y = 0; y < BITMAP_HEIGHT; y++) + for (int x = 0; x < BITMAP_WIDTH; x++) + if (bitmapPixel(bitmap, x, y)) + { + right = x > right ? x : right; + bottom = y > bottom ? y : bottom; + } + icon = Icon(); + icon.width = right + 1; + icon.height = bottom + 1; + for (int y = 0; y < icon.height; y++) + for (int x = 0; x < icon.width; x++) + if (bitmapPixel(bitmap, x, y)) + setIconPixel(icon, x, y, true); +} diff --git a/src/esp32/logic/icon_format.h b/src/esp32/logic/icon_format.h new file mode 100644 index 0000000..f8a516b --- /dev/null +++ b/src/esp32/logic/icon_format.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include "logic/bitmap_format.h" + +// Icons: small bitmaps of their own size (1 x 1 up to 84 x 19) that pages place as images +// or backgrounds; they are never shown on their own. Rows from top to bottom, each +// (width + 7) / 8 bytes, the most significant bit is the leftmost dot (like the full +// screen bitmaps of logic/bitmap_format.h). Icons share the names of bitmaps and pages. +// Pure logic, tested in test/test_esp32. + +const int ICON_MAX_WIDTH = BITMAP_WIDTH; +const int ICON_MAX_HEIGHT = BITMAP_HEIGHT; + +struct Icon +{ + uint8_t width = 0; + uint8_t height = 0; + uint8_t data[BITMAP_BYTES] = {}; // iconBytes(width, height) are used +}; + +inline int iconRowBytes(int width) +{ + return (width + 7) / 8; +} +inline int iconBytes(int width, int height) +{ + return iconRowBytes(width) * height; +} +bool validIconSize(int width, int height); + +bool iconPixel(const Icon &icon, int x, int y); +void setIconPixel(Icon &icon, int x, int y, bool on); + +// Exactly iconBytes(width, height) * 2 hex digits. Returns false on anything else. +bool iconFromHex(int width, int height, const char *hex, Icon &icon); +// out must hold BITMAP_HEX_LENGTH + 1 characters +void iconToHex(const Icon &icon, char *out); + +// The icon in a full screen bitmap, with its top left corner at 0, 0 +void iconToBitmap(const Icon &icon, uint8_t *bitmap); +// A full screen bitmap as an icon that reaches from 0, 0 to its last dot on the right and +// at the bottom, so it is drawn on the same dots (1 x 1 when it has no dots) +void iconFromBitmap(const uint8_t *bitmap, Icon &icon); diff --git a/src/esp32/logic/page.cpp b/src/esp32/logic/page.cpp index f6762f4..07e4deb 100644 --- a/src/esp32/logic/page.cpp +++ b/src/esp32/logic/page.cpp @@ -5,6 +5,7 @@ #include #include #include +#include static const uint16_t MINUTES_PER_DAY = 24 * 60; static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01 @@ -70,6 +71,31 @@ bool elementUsesHa(const PageElement &element) } } +bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state) +{ + for (int i = 0; i < page.slot_count; i++) + if (sameName(page.slots[i].bitmap, name)) + return true; + for (int i = 0; i < page.element_count; i++) + { + const PageElement &e = page.elements[i]; + if (e.type != ElementType::Image) + continue; + if (!e.entity[0]) + { + if (sameName(e.text, name)) + return true; + continue; + } + size_t start = strlen(e.text); + if (strncasecmp(name, e.text, start) != 0) + continue; + if (by_state ? name[start] != '\0' : strcasecmp(name + start, "default") == 0) + return true; + } + return false; +} + bool validEntityId(const char *entity) { if (entity == nullptr) diff --git a/src/esp32/logic/page.h b/src/esp32/logic/page.h index 4821c60..9270869 100644 --- a/src/esp32/logic/page.h +++ b/src/esp32/logic/page.h @@ -223,6 +223,11 @@ void plexUserSlug(const char *name, char *out, size_t out_size); // The element shows a Home Assistant value (HaValue; Image, Bar with an entity) bool elementUsesHa(const PageElement &element); +// Whether the page uses this bitmap or icon (compared without case): as a background, as +// a fixed image, or as the "default" image of an image with an entity. by_state: also the +// names an image with an entity may show (its name start + any state). +bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state = false); + // Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.'); // {plex_user} may be in them bool validEntityId(const char *entity); diff --git a/src/esp32/main.cpp b/src/esp32/main.cpp index eeba7a3..37c5d0b 100644 --- a/src/esp32/main.cpp +++ b/src/esp32/main.cpp @@ -81,6 +81,9 @@ void loop() homeAssistantLoop(); webUiLoop(); appLoop(); + // Once the firmware can't be rolled back anymore (the old one doesn't know icons) + if (!firmwareGuardPending()) + appMigrateIcons(); // Home Assistant values of the shown page (read by a task, never blocks) if (haApiLoop(wifiConnected())) controller.pageChanged(); diff --git a/src/esp32/system/firmware_guard.cpp b/src/esp32/system/firmware_guard.cpp index 7f87d81..2d532d8 100644 --- a/src/esp32/system/firmware_guard.cpp +++ b/src/esp32/system/firmware_guard.cpp @@ -138,6 +138,11 @@ void firmwareGuardStopWatchdog() watchdog_running = false; } +bool firmwareGuardPending() +{ + return firmware_pending; +} + void firmwareGuardLoop(bool wifi_connected) { if (watchdog_running) diff --git a/src/esp32/system/firmware_guard.h b/src/esp32/system/firmware_guard.h index 8be6c04..e4af53f 100644 --- a/src/esp32/system/firmware_guard.h +++ b/src/esp32/system/firmware_guard.h @@ -31,6 +31,9 @@ void firmwareGuardStopWatchdog(); // Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter void firmwareGuardLoop(bool wifi_connected); +// A new OTA firmware that is not marked as good yet (it can still be rolled back) +bool firmwareGuardPending(); + // Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok" const char *firmwareGuardStatus(); diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 92a1c2b..714316a 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -37,6 +37,9 @@ button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);bor button.d{color:var(--bad)} #editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move} #f_canvas{width:100%;background:#000;border-radius:8px} +#ic_canvas{max-width:100%;height:auto;display:block;background:#000;border-radius:8px;touch-action:none;cursor:crosshair} +.icbox{height:64px;display:flex;align-items:center;justify-content:center;background:#000;border-radius:4px} +.item .icbox canvas{width:auto;height:auto;max-width:100%;max-height:64px;border-radius:0} details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px} .el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)} .form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center} @@ -60,7 +63,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m

Dynamic pages

-

A background per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.

+

A background (a bitmap or an icon) per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.

+ +

Icons

+
+

Small images for dynamic pages: as an Image element or as a background (drawn at the top left). They are never shown on their own, so they don't appear in the lists of pages. Make bitmap turns one into a full screen bitmap.

+

Draw / upload

@@ -122,8 +141,9 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
-
-

Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).

+
+
+

Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite). Save as icon keeps only the area with dots.

Schedule

@@ -183,12 +203,12 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m

-

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

+

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