Icons: small images of their own size for pages

Bitmaps that only pages use (backgrounds, images by state) are now icons:
own width and height, stored in /icons, not in the page lists or the Home
Assistant dropdowns. Page backgrounds and image elements use icons or
bitmaps; the renderer gets icons expanded at the top left, so pages look
the same. Web UI: Icons tab with an editor, Make icon / Make bitmap, Save
as icon on the Draw tab, icons in the backup.

A one-time migration in time-boxed steps moves the uploaded bitmaps that
only pages use, after the new firmware is marked good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 22:03:18 +02:00
1 parent 1704cbf17b
commit ab001e5d18
37 files changed
+955 -78

No files matched your search

+10
View File
@@ -149,6 +149,16 @@ See README.md for the overview.
`VERSION` there (old records then fall back to defaults). `VERSION` there (old records then fall back to defaults).
- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names - Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names
are restricted (`validBitmapName`), which also makes them safe in paths and JSON. are restricted (`validBitmapName`), which also makes them safe in paths and JSON.
- Icons (`logic/icon_format.h`, `display/icon_store`, `/icons/<name>.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`). - 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 - Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a
versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage` versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 150 KiB

+1 -1
View File
@@ -57,7 +57,7 @@ Assistant's Plex and Tautulli integrations, no extra firmware:
- `sensor.mangavalkserver` (Plex): how many are watching. - `sensor.mangavalkserver` (Plex): how many are watching.
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon - `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): - `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 title and the progress bar. Home Assistant creates these disabled: enable them on
the Tautulli user's device in Home Assistant. the Tautulli user's device in Home Assistant.
+31 -13
View File
@@ -13,7 +13,7 @@ headless mode).
## Show ## Show
Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: 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 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. "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. 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 ## Pages
Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu
Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it 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 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). 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 | | 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 | | 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 | | 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 | | 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 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, 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 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) ![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 / Live
Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the 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 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 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) ![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). how often the image was repaired, Nano restarts and `ERR` replies (with the last one).
**Memory:** free heap now and the lowest since boot. **Memory:** free heap now and the lowest since boot.
**Backup:** export everything (settings, the schedule and rules, your bitmaps, pages and **Backup:** export everything (settings, the schedule and rules, your bitmaps, icons,
fonts; the MQTT password and HA token only when ticked) to one JSON file, and import a pages and fonts; the MQTT password and HA token only when ticked) to one JSON file, and
file: choose which items and which parts of the settings. Imported items are added (a import a file: choose which items and which parts of the settings. Imported items are
taken name gets a number) unless you give them an existing name. Pages and bitmaps also added (a taken name gets a number) unless you give them an existing name. Pages, bitmaps
have their own Export button, e.g. to share one page (with the bitmaps and fonts it uses). 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) ![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 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. 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 **Bitmaps, icons, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB; up to 200
a thousand bitmaps, 50 pages, 20 fonts). Pages and bitmaps share their names. They need 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); the new flash layout from the [one-time installation](recovery.md#one-time-installation-usb);
before that, only the built-in ones are available. before that, only the built-in ones are available.
+2
View File
@@ -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/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/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/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.*` | 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/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 | | `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/`** | **What is drawn** |
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) | | `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/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/<name>.pg`) | | `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
| `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) | | `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) |
| `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values | | `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values |
+185 -3
View File
@@ -7,6 +7,7 @@
#include "config.h" #include "config.h"
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/font_store.h" #include "display/font_store.h"
#include "display/icon_store.h"
#include "display/page_resources.h" #include "display/page_resources.h"
#include "display/page_store.h" #include "display/page_store.h"
#include "logic/page_selector.h" #include "logic/page_selector.h"
@@ -274,6 +275,8 @@ const char *appSaveBitmap(const char *name, const uint8_t *bitmap)
{ {
if (pageExists(name)) if (pageExists(name))
return "A page has this name"; return "A page has this name";
if (iconExists(name))
return "An icon has this name";
const char *error = saveBitmap(name, bitmap); const char *error = saveBitmap(name, bitmap);
if (error) if (error)
return error; return error;
@@ -297,7 +300,7 @@ const char *appDeleteBitmap(const char *name)
{ {
if (scheduleUses(page_schedule, name)) if (scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE; return USED_BY_SCHEDULE;
String user = pageUsingBitmap(name); String user = pageUsingImage(name);
if (user.length()) if (user.length())
return usedBy(user); return usedBy(user);
const char *error = deleteBitmap(name); const char *error = deleteBitmap(name);
@@ -314,9 +317,11 @@ const char *appSavePage(const char *name, const DynamicPage &page)
{ {
if (bitmapExists(name)) if (bitmapExists(name))
return "A bitmap has this 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++) for (int i = 0; i < page.slot_count; i++)
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap)) if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap) && !iconExists(page.slots[i].bitmap))
return "Background bitmap not found"; return "Background bitmap or icon not found";
for (int i = 0; i < page.element_count; i++) for (int i = 0; i < page.element_count; i++)
if (!fontExists(page.elements[i].font)) if (!fontExists(page.elements[i].font))
return "Font not found"; return "Font not found";
@@ -340,6 +345,183 @@ const char *appDeletePage(const char *name)
return nullptr; 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 *appSaveFont(const char *name, const uint8_t *data, size_t size)
{ {
const char *error = saveFont(name, data, size); const char *error = saveFont(name, data, size);
+13 -3
View File
@@ -7,6 +7,7 @@
#include "display/frames.h" #include "display/frames.h"
#include "display/status_screen.h" #include "display/status_screen.h"
#include "logic/display_controller.h" #include "logic/display_controller.h"
#include "logic/icon_format.h"
#include "logic/local_time.h" #include "logic/local_time.h"
#include "logic/loop_stats.h" #include "logic/loop_stats.h"
#include "logic/page.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 // Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
const char *appSelectOffPage(const char *name); const char *appSelectOffPage(const char *name);
// Bitmaps, dynamic pages and fonts: stored, and the display redrawn when it shows them. // Bitmaps, icons, dynamic pages and fonts: stored, and the display redrawn when it shows
// Bitmaps and pages share one namespace; what a page uses can't be deleted. All return // them. Bitmaps, icons and pages share one namespace; what a page uses can't be deleted.
// nullptr or an error message. // All return nullptr or an error message.
const char *appSaveBitmap(const char *name, const uint8_t *bitmap); const char *appSaveBitmap(const char *name, const uint8_t *bitmap);
const char *appDeleteBitmap(const char *name); 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 *appSavePage(const char *name, const DynamicPage &page);
const char *appDeletePage(const char *name); const char *appDeletePage(const char *name);
const char *appSaveFont(const char *name, const uint8_t *data, size_t size); 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_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; 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) // 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}; const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+6 -1
View File
@@ -51,7 +51,7 @@ static String path(const char *name)
bool bitmapStoreBegin() bool bitmapStoreBegin()
{ {
mounted = LittleFS.begin(true); mounted = LittleFS.begin(true);
for (const char *dir : {DIR, "/pages", "/fonts"}) for (const char *dir : {DIR, "/pages", "/fonts", "/icons"})
if (mounted && !LittleFS.exists(dir)) if (mounted && !LittleFS.exists(dir))
LittleFS.mkdir(dir); LittleFS.mkdir(dir);
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)"); Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
@@ -108,6 +108,11 @@ uint32_t bitmapListRevision()
return revision; return revision;
} }
bool builtinBitmap(const char *name)
{
return builtin(name) != nullptr;
}
bool bitmapExists(const char *name) bool bitmapExists(const char *name)
{ {
if (builtin(name)) if (builtin(name))
+2
View File
@@ -31,6 +31,8 @@ std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted // Changes each time a bitmap is saved or deleted
uint32_t bitmapListRevision(); uint32_t bitmapListRevision();
bool bitmapExists(const char *name); bool bitmapExists(const char *name);
// Without reading the storage
bool builtinBitmap(const char *name);
bool loadBitmap(const char *name, uint8_t *bitmap); bool loadBitmap(const char *name, uint8_t *bitmap);
// Return nullptr on success, otherwise a message for the user // Return nullptr on success, otherwise a message for the user
+165
View File
@@ -0,0 +1,165 @@
#include "display/icon_store.h"
#include <LittleFS.h>
#include <algorithm>
#include <map>
#include <strings.h>
#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<String, CachedIcon> 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<String> listIcons()
{
std::vector<String> 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();
}
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
#include <vector>
#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/<name>.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<String> 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();
+8 -1
View File
@@ -4,6 +4,7 @@
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/font_store.h" #include "display/font_store.h"
#include "display/icon_store.h"
#include "net/ha_api.h" #include "net/ha_api.h"
Stopwatch stopwatch; Stopwatch stopwatch;
@@ -11,7 +12,13 @@ StoredPageResources pageResources(stopwatch);
bool StoredPageResources::background(const char *name, uint8_t *bitmap) 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) Font StoredPageResources::font(const char *name)
+1 -1
View File
@@ -3,7 +3,7 @@
#include "logic/page_render.h" #include "logic/page_render.h"
#include "logic/stopwatch.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 // from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
// web UI // web UI
class StoredPageResources : public PageResources class StoredPageResources : public PageResources
+2 -7
View File
@@ -458,14 +458,9 @@ template <typename F> static String findPage(F uses)
return found; return found;
} }
String pageUsingBitmap(const char *bitmap) String pageUsingImage(const char *name)
{ {
return findPage([bitmap](const DynamicPage &page) { return findPage([name](const DynamicPage &page) { return pageUsesImage(page, name); });
for (int i = 0; i < page.slot_count; i++)
if (sameName(page.slots[i].bitmap, bitmap))
return true;
return false;
});
} }
String pageUsingFont(const char *font) String pageUsingFont(const char *font)
+3 -2
View File
@@ -30,6 +30,7 @@ bool loadPage(const char *name, DynamicPage &page);
const char *savePage(const char *name, const DynamicPage &page); const char *savePage(const char *name, const DynamicPage &page);
const char *deletePage(const char *name); const char *deletePage(const char *name);
// The first stored page that uses the bitmap (as background) or the font; empty if none // The first stored page that uses the bitmap or icon (logic/page.h pageUsesImage) or the
String pageUsingBitmap(const char *bitmap); // font; empty if none
String pageUsingImage(const char *name);
String pageUsingFont(const char *font); String pageUsingFont(const char *font);
+103
View File
@@ -0,0 +1,103 @@
#include "logic/icon_format.h"
#include <string.h>
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);
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <stdint.h>
#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);
+26
View File
@@ -5,6 +5,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <strings.h>
static const uint16_t MINUTES_PER_DAY = 24 * 60; static const uint16_t MINUTES_PER_DAY = 24 * 60;
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01 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) bool validEntityId(const char *entity)
{ {
if (entity == nullptr) if (entity == nullptr)
+5
View File
@@ -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) // The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
bool elementUsesHa(const PageElement &element); 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 '.'); // Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
// {plex_user} may be in them // {plex_user} may be in them
bool validEntityId(const char *entity); bool validEntityId(const char *entity);
+3
View File
@@ -81,6 +81,9 @@ void loop()
homeAssistantLoop(); homeAssistantLoop();
webUiLoop(); webUiLoop();
appLoop(); 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) // Home Assistant values of the shown page (read by a task, never blocks)
if (haApiLoop(wifiConnected())) if (haApiLoop(wifiConnected()))
controller.pageChanged(); controller.pageChanged();
+5
View File
@@ -138,6 +138,11 @@ void firmwareGuardStopWatchdog()
watchdog_running = false; watchdog_running = false;
} }
bool firmwareGuardPending()
{
return firmware_pending;
}
void firmwareGuardLoop(bool wifi_connected) void firmwareGuardLoop(bool wifi_connected)
{ {
if (watchdog_running) if (watchdog_running)
+3
View File
@@ -31,6 +31,9 @@ void firmwareGuardStopWatchdog();
// Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter // Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter
void firmwareGuardLoop(bool wifi_connected); 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" // Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
const char *firmwareGuardStatus(); const char *firmwareGuardStatus();
+120 -44
View File
@@ -37,6 +37,9 @@ button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);bor
button.d{color:var(--bad)} button.d{color:var(--bad)}
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move} #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} #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} 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)} .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} .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
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div> <section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
<div class="row"><button class="b" onclick="peNew()">New page</button></div> <div class="row"><button class="b" onclick="peNew()">New page</button></div>
<p class="hint">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.</p></div> <p class="hint">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.</p></div>
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2> <div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button> <div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button> <button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
@@ -109,7 +112,23 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div> <section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details> <details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
<p class="hint" id="storage"></p></div></section> <p class="hint" id="storage"></p>
<p class="hint">Bitmaps fill the whole display and can be shown on their own. A bitmap that is only part of pages can be made an icon (Make icon).</p></div></section>
<section id="t-icons"><div class="card"><h2>Icons</h2><div class="grid" id="icons"></div>
<div class="row"><button class="b" onclick="icNew()">New icon</button></div>
<p class="hint">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.</p></div>
<div class="card" id="ic" style="display:none"><h2 id="ic_title">Icon</h2>
<div class="row"><input id="ic_name" placeholder="name" maxlength="24"><button class="b" onclick="icSave()">Save</button>
<button class="s" onclick="icLive()">Show on display now</button><button class="s" onclick="icClose()">Close</button></div>
<div class="row"><label>Width <input type="number" id="ic_w" min="1" max="84" style="width:4.5em"></label>
<label>Height <input type="number" id="ic_h" min="1" max="19" style="width:4.5em"></label>
<button class="s" onclick="icCrop()">Crop to the dots</button><button class="s" onclick="icClear()">Clear</button><button class="s" onclick="icInvert()">Invert</button>
<label><input type="file" id="ic_file" accept="image/*"> Image</label></div>
<div class="row" id="ic_imgopts" style="display:none"><label>Threshold <input type="range" id="ic_thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="ic_inv"> Invert</label><label><input type="checkbox" id="ic_dith"> Dither</label></div>
<canvas id="ic_canvas" width="840" height="190"></canvas>
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div></section>
<section id="t-draw"><div class="card"><h2>Draw / upload</h2> <section id="t-draw"><div class="card"><h2>Draw / upload</h2>
<canvas id="editor" width="840" height="190"></canvas> <canvas id="editor" width="840" height="190"></canvas>
@@ -122,8 +141,9 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button> <div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
<label><input type="checkbox" id="autolive"> live while drawing</label> <label><input type="checkbox" id="autolive"> live while drawing</label>
<button class="s" onclick="stopLive()">Back to normal</button></div> <button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button></div> <div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button>
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).</p></div></section> <button class="s" onclick="saveAsIcon()">Save as icon</button></div>
<p class="hint">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.</p></div></section>
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form"> <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>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
@@ -183,12 +203,12 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button> <div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button>
<button class="b" onclick="importSel()">Import selected</button></div></div> <button class="b" onclick="importSel()">Import selected</button></div></div>
<p class="hint" id="i_result"></p> <p class="hint" id="i_result"></p>
<p class="hint">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.</p></div></section> <p class="hint">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.</p></div></section>
</main><div id="msg"></div> </main><div id="msg"></div>
<script> <script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id); const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[],sched=null,schedDirty=false; let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']]; const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)}); tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id} function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)} function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
@@ -200,9 +220,19 @@ function mk(tag,props,...kids){const e=document.createElement(tag);Object.assign
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format // bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b} function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h} function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height); function draw(c,b,w=W,h=H){const g=c.getContext('2d'),s=c.width/w;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}} for(let y=0;y<h;y++)for(let x=0;x<w;x++){g.fillStyle=b[y*w+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c} function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
// icons: Uint8Array(w*h) <-> hex, rows of (w+7)>>3 bytes (src/esp32/logic/icon_format.h)
function icFromHex(w,h,hx){const rb=(w+7)>>3,b=new Uint8Array(w*h);for(let y=0;y<h;y++)for(let x=0;x<w;x++)b[y*w+x]=(parseInt(hx.substr((y*rb+(x>>3))*2,2),16)>>(7-(x&7)))&1;return b}
function icToHex(b,w,h){let hx='';const rb=(w+7)>>3;for(let y=0;y<h;y++)for(let i=0;i<rb;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<w&&b[y*w+x])v|=0x80>>k}hx+=v.toString(16).padStart(2,'0')}return hx}
// the icon at the top left of a full screen bitmap (how a background shows it)
function icFull(b,w,h){const f=new Uint8Array(W*H);for(let y=0;y<h;y++)for(let x=0;x<w;x++)f[y*W+x]=b[y*w+x];return f}
// the part of an image with dots: {bits,w,h,x,y}; a blank dot when there are none
function cropBits(b,w,h){let x0=w,y0=h,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++)if(b[y*w+x]){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y)}
if(x1<0)return{bits:new Uint8Array(1),w:1,h:1,x:0,y:0};const cw=x1-x0+1,ch=y1-y0+1,o=new Uint8Array(cw*ch);
for(let y=0;y<ch;y++)for(let x=0;x<cw;x++)o[y*cw+x]=b[(y+y0)*w+x+x0];return{bits:o,w:cw,h:ch,x:x0,y:y0}}
function icCanvas(b,w,h){const s=Math.max(2,Math.min(16,Math.floor(336/w),Math.floor(76/h))),c=mk('canvas',{width:w*s,height:h*s});draw(c,b,w,h);return mk('div',{className:'icbox'},c)}
// mirror of the display // mirror of the display
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data)); async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){} $('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
@@ -229,9 +259,13 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
(p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text); (p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})} else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u} if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
(page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'} function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
async function loadLists(){const[b,p,f,sc]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks')]); async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data)); bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data));if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).'; $('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps(); const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML=''); ['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
@@ -247,7 +281,14 @@ async function loadLists(){const[b,p,f,sc]=await Promise.all([getList('/api/bitm
$(u?'offpages_used':'offpages').appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name))); $(u?'offpages_used':'offpages').appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'}); const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')})); r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
if(!bm.builtin)r.append(btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)} if(!bm.builtin)r.append(btn('Make icon',()=>toIcon(bm.name)),btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
const ics=$('icons'),users=pageList.map(p=>[p.name,pageImages(p.page)]);ics.innerHTML='';
for(const x of iconList){const by=users.filter(([,s])=>s.has(x.name.toLowerCase())).map(([n])=>n);
const d=mk('div',{className:'item'},icCanvas(x.bits,x.width,x.height),mk('div',{className:'name',textContent:x.name}),
mk('div',{className:'tag',textContent:x.width+' x '+x.height+', '+(by.length?'on '+by.join(', '):'not used'),title:by.join(', ')}),
mk('div',{className:'row'},btn('Edit',()=>icOpen(x)),btn('Make bitmap',()=>toBitmap(x.name)),btn('Export',()=>exportItem('icon',x.name)),btn('Delete',()=>delIcon(x.name),'d')));
ics.appendChild(d)}
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused)); const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
schedBuild(); schedBuild();
const fl=$('fontlist');fl.innerHTML=''; const fl=$('fontlist');fl.innerHTML='';
@@ -259,6 +300,9 @@ function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()} async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()} async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()} async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()} async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()} async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)} function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
@@ -327,14 +371,15 @@ function chk(o,k,label){const c=mk('input',{type:'checkbox',checked:!!o[k]});c.o
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i} function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k]; function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s} s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name])); function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(iconList.map(x=>[x.name,x.name+' (icon)']),bitmapList.map(b=>[b.name,b.name+' (bitmap)']));
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort; pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',opts),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))}); const o=s.bitmap&&!opts.some(x=>same(x[0],s.bitmap))?opts.concat([[s.bitmap,s.bitmap+' (gone)']]):opts;
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',o),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))} if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()} function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0} function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()} function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...bitmapList.map(b=>mk('option',{value:b.name})))); function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...iconList.concat(bitmapList).map(b=>mk('option',{value:b.name}))));
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}}; pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))]; const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
const row=(...k)=>rows.push(mk('div',{className:'row'},...k)); const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
@@ -363,9 +408,9 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16}))); field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))} row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames'); if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})), row(field(e.entity?'Icon name start':'Icon',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e))); field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))} rows.push(mk('p',{className:'hint',textContent:'An icon (Icons tab) or a bitmap. With an entity the icon is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})), if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e))); field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})), row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
@@ -449,19 +494,22 @@ const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a} function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file}); function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
document.body.append(a);a.click();a.remove()} document.body.append(a);a.click();a.remove()}
function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],pages:[],fonts:[]}} function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],icons:[],pages:[],fonts:[]}}
const icItem=x=>({name:x.name,width:x.width,height:x.height,data:x.data});
async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}} async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}}
async function exportAll(){const sec=$('x_secrets').checked,out=backup(); async function exportAll(){const sec=$('x_secrets').checked,out=backup();
const[s,b,p,f]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]); const[s,b,p,f,ic]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts'),api('/api/icons')]);
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;out.schedule=await api('/api/schedule'); delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;out.schedule=await api('/api/schedule');
out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page})); out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.icons=ic.icons.map(icItem);out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page}));
for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name)); for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name));
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.pages.length+' pages, '+out.fonts.length+' fonts')} download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.icons.length+' icons, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
// one bitmap, or one page with the bitmaps and fonts it uses (not the built-in ones), without settings // one bitmap or icon, or one page with the bitmaps, icons and fonts it uses (not the built-in ones), without settings
async function exportItem(kind,name){const out=backup(); async function exportItem(kind,name){const out=backup();
if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})} if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})}
else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page}); else if(kind=='icon')out.icons.push(icItem(iconList.find(x=>same(x.name,name))));
for(const s of p.page.slots){const b=bitmapList.find(x=>same(x.name,s.bitmap));if(b&&!b.builtin&&!out.bitmaps.some(x=>same(x.name,b.name)))out.bitmaps.push({name:b.name,data:b.data})} else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page});const used=pageImages(p.page);
for(const b of bitmapList)if(!b.builtin&&used.has(b.name.toLowerCase()))out.bitmaps.push({name:b.name,data:b.data});
for(const x of iconList)if(used.has(x.name.toLowerCase()))out.icons.push(icItem(x));
for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}} for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}}
download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)} download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)}
// import: settings only when ticked; items are added under a free name unless you give them an existing one // import: settings only when ticked; items are added under a free name unless you give them an existing one
@@ -477,32 +525,33 @@ function impShow(){const d=$('i_list');d.innerHTML='';impRows=[];const ha=(imp.s
if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'})); if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule'; const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule';
d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))} d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))}
// bitmaps and pages share their names, fonts have their own // bitmaps, icons and pages share their names, fonts have their own
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)), const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList,iconList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)),
takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n)); takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n));
const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title})); const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title}));
list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name); list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name);
const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})}; const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})};
row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})}; row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})};
group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Dynamic pages','p',imp.pages||[]); group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Icons','i',imp.icons||[]);group('Dynamic pages','p',imp.pages||[]);
$('i_box').style.display='block'} $('i_box').style.display='block'}
function impNote(r){const n=r.i.value.trim(),own=r.kind=='f'?fontList:r.kind=='b'?bitmapList:pageList,other=r.kind=='b'?pageList:r.kind=='p'?bitmapList:[]; function impNote(r){const n=r.i.value.trim(),lists={f:fontList,b:bitmapList,i:iconList,p:pageList},words={b:'bitmap',i:'icon',p:'page'};
const replaces=own.some(x=>same(x.name,n)),clash=other.some(x=>same(x.name,n)); const replaces=lists[r.kind].some(x=>same(x.name,n)),clash=r.kind=='f'?'':['b','i','p'].find(k=>k!=r.kind&&lists[k].some(x=>same(x.name,n)));
r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+(r.kind=='b'?'page':'bitmap'):'added'+(n!=r.item.name?' (was '+r.item.name+')':''); r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+words[clash]:'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
r.note.style.color=!n||replaces||clash?'var(--bad)':''} r.note.style.color=!n||replaces||clash?'var(--bad)':''}
function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)} function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)}
// fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last // fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last
async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key)); async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key));
const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return} const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return}
const errors=[],rename={f:{},b:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...'; const errors=[],rename={f:{},b:{},i:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...';
const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}}; const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}};
for(const kind of['f','b','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim(); for(const kind of['f','b','i','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim();
if(!n){errors.push(r.item.name+': no name');continue} if(!n){errors.push(r.item.name+': no name');continue}
let ok; let ok;
if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data}); if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data});
else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data}); else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data});
else{const page=JSON.parse(JSON.stringify(r.item.page));(page.slots||[]).forEach(s=>s.bitmap=to('b',s.bitmap)||s.bitmap); else if(kind=='i')ok=await run('Icon '+n,'/api/icon',{name:n,width:r.item.width,height:r.item.height,data:r.item.data});
(page.elements||[]).forEach(e=>e.font=to('f',e.font)||e.font);ok=await run('Page '+n,'/api/pages/save',{name:n,page})} else{const page=JSON.parse(JSON.stringify(r.item.page)),img=x=>to('i',x)||to('b',x)||x;(page.slots||[]).forEach(s=>s.bitmap=img(s.bitmap));
(page.elements||[]).forEach(e=>{e.font=to('f',e.font)||e.font;if(e.type=='image'&&!e.entity)e.text=img(e.text)});ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
if(ok)rename[kind][r.item.name.toLowerCase()]=n} if(ok)rename[kind][r.item.name.toLowerCase()]=n}
await loadLists(); await loadLists();
if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings)); if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings));
@@ -529,18 +578,45 @@ function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)} async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return} async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()} await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
// image conversion async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists()}
// image conversion: w x h dots, dark parts on
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)}; $('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert); ['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d'); function imgBits(img,w,h,thr,inv,dith,fit){const c=document.createElement('canvas');c.width=w;c.height=h;const g=c.getContext('2d');
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.height;let dw=W,dh=H; g.fillStyle='#fff';g.fillRect(0,0,w,h);const iw=img.width,ih=img.height;let dw=w,dh=h;
if(fit!='stretch'){const s=fit=='contain'?Math.min(W/iw,H/ih):Math.max(W/iw,H/ih);dw=iw*s;dh=ih*s} if(fit!='stretch'){const s=fit=='contain'?Math.min(w/iw,h/ih):Math.max(w/iw,h/ih);dw=iw*s;dh=ih*s}
g.drawImage(srcImg,(W-dw)/2,(H-dh)/2,dw,dh);const d=g.getImageData(0,0,W,H).data,l=new Float32Array(W*H); g.drawImage(img,(w-dw)/2,(h-dh)/2,dw,dh);const d=g.getImageData(0,0,w,h).data,l=new Float32Array(w*h);
for(let i=0;i<W*H;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2]; for(let i=0;i<w*h;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
const thr=+$('thr').value,b=new Uint8Array(W*H),dith=$('dith').checked,inv=$('inv').checked; const b=new Uint8Array(w*h);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0; for(let y=0;y<h;y++)for(let x=0;x<w;x++){const i=y*w+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
if(dith){const e=l[i]-o;if(x+1<W)l[i+1]+=e*7/16;if(y+1<H){if(x>0)l[i+W-1]+=e*3/16;l[i+W]+=e*5/16;if(x+1<W)l[i+W+1]+=e/16}}} if(dith){const e=l[i]-o;if(x+1<w)l[i+1]+=e*7/16;if(y+1<h){if(x>0)l[i+w-1]+=e*3/16;l[i+w]+=e*5/16;if(x+1<w)l[i+w+1]+=e/16}}}
ed.set(b);changed()} return b}
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
// icon editor: its own size, drawn like the bitmap editor
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
function icOpen(x){ic={orig:x.name,w:x.width,h:x.height,bits:x.bits.slice()};icShow('Edit '+x.name)}
function icNew(){ic={orig:'',w:11,h:11,bits:new Uint8Array(121)};icShow('New icon')}
function icClose(){ic=null;$('ic').style.display='none'}
function icDraw(){const s=Math.max(4,Math.min(40,Math.floor(840/ic.w),Math.floor(320/ic.h)));icc.width=ic.w*s;icc.height=ic.h*s;draw(icc,ic.bits,ic.w,ic.h)}
function icResize(w,h,bits,bw,bh){const b=new Uint8Array(w*h);for(let y=0;y<Math.min(h,bh);y++)for(let x=0;x<Math.min(w,bw);x++)b[y*w+x]=bits[y*bw+x];return b}
function icSize(){const w=Math.min(84,Math.max(1,Math.round(+$('ic_w').value||1))),h=Math.min(19,Math.max(1,Math.round(+$('ic_h').value||1)));
if(w==ic.w&&h==ic.h)return;ic.bits=icResize(w,h,ic.bits,ic.w,ic.h);ic.w=w;ic.h=h;if(icImg)icConvert();icDraw()}
$('ic_w').onchange=icSize;$('ic_h').onchange=icSize;
function icCrop(){const c=cropBits(ic.bits,ic.w,ic.h);Object.assign(ic,{bits:c.bits,w:c.w,h:c.h});$('ic_w').value=c.w;$('ic_h').value=c.h;icDraw()}
function icClear(){ic.bits=new Uint8Array(ic.w*ic.h);icDraw()}
function icInvert(){ic.bits=ic.bits.map(v=>1-v);icDraw()}
function icPos(e){const r=icc.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*ic.w),Math.floor((e.clientY-r.top)/r.height*ic.h)]}
function icDot(e){const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h||ic.bits[y*ic.w+x]==icPaint)return;ic.bits[y*ic.w+x]=icPaint;icDraw()}
icc.onpointerdown=e=>{if(!ic)return;const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h)return;icPaint=ic.bits[y*ic.w+x]?0:1;icc.setPointerCapture(e.pointerId);icDot(e)};
icc.onpointermove=e=>{if(icPaint!==null)icDot(e)};icc.onpointerup=()=>{icPaint=null};
$('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=new Image();img.onload=()=>{icImg=img;$('ic_imgopts').style.display='flex';icConvert()};img.src=URL.createObjectURL(f)};
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert());
function icConvert(){if(!icImg||!ic)return;ic.bits=imgBits(icImg,ic.w,ic.h,+$('ic_thr').value,$('ic_inv').checked,$('ic_dith').checked,'contain');icDraw()}
async function icSave(){const n=$('ic_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
// test patterns (live mode) // test patterns (live mode)
let testTimer=null; let testTimer=null;
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]= function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
+73
View File
@@ -10,6 +10,7 @@
#include "config.h" #include "config.h"
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/font_store.h" #include "display/font_store.h"
#include "display/icon_store.h"
#include "display/page_resources.h" #include "display/page_resources.h"
#include "display/page_store.h" #include "display/page_store.h"
#include "logic/bitmap_format.h" #include "logic/bitmap_format.h"
@@ -303,6 +304,73 @@ static void handleBitmapDelete()
sendResult(appDeleteBitmap(body["name"] | "")); sendResult(appDeleteBitmap(body["name"] | ""));
} }
static void handleBitmapToIcon()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appBitmapToIcon(body["name"] | ""));
}
// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------
// Streamed like the bitmaps
static void handleIcons()
{
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
String out = "{\"icons\":[";
bool first = true;
Icon icon;
char hex[BITMAP_HEX_LENGTH + 1];
for (const String &name : listIcons())
{
if (!loadIcon(name.c_str(), icon))
continue;
iconToHex(icon, hex);
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
out += String(first ? "" : ",") + "{\"name\":\"" + name + "\",\"width\":" + icon.width +
",\"height\":" + icon.height + ",\"data\":\"" + hex + "\"}";
first = false;
sendPiece(out);
}
out += "]}";
sendPiece(out, true);
}
static void handleIconSave()
{
JsonDocument body;
if (!readBody(body))
return;
Icon icon;
if (!iconFromHex(body["width"] | 0, body["height"] | 0, body["data"] | "", icon))
{
sendError(400, "Invalid icon: 1 x 1 to 84 x 19 dots, rows of whole bytes in hex");
return;
}
sendResult(appSaveIcon(body["name"] | "", icon));
}
static void handleIconDelete()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appDeleteIcon(body["name"] | ""));
}
static void handleIconToBitmap()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appIconToBitmap(body["name"] | ""));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Dynamic pages // Dynamic pages
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -751,6 +819,11 @@ void webUiBegin()
server.on("/api/bitmap", HTTP_GET, handleBitmapGet); server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
server.on("/api/bitmap", HTTP_POST, handleBitmapSave); server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete); server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete);
server.on("/api/bitmap/to_icon", HTTP_POST, handleBitmapToIcon);
server.on("/api/icons", HTTP_GET, handleIcons);
server.on("/api/icon", HTTP_POST, handleIconSave);
server.on("/api/icon/delete", HTTP_POST, handleIconDelete);
server.on("/api/icon/to_bitmap", HTTP_POST, handleIconToBitmap);
server.on("/api/pages", HTTP_GET, handlePages); server.on("/api/pages", HTTP_GET, handlePages);
server.on("/api/pages/save", HTTP_POST, handlePageSave); server.on("/api/pages/save", HTTP_POST, handlePageSave);
server.on("/api/pages/delete", HTTP_POST, handlePageDelete); server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
+7 -1
View File
@@ -1,7 +1,7 @@
#pragma once #pragma once
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display, // Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display,
// choosing the page, dynamic pages and fonts, bitmaps (upload, draw, store), live mode, // choosing the page, dynamic pages and fonts, bitmaps (upload, draw, store), icons, live mode,
// settings and Home Assistant. The page itself is in web/web_page.h. // settings and Home Assistant. The page itself is in web/web_page.h.
// //
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h): // JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
@@ -11,6 +11,12 @@
// GET /api/bitmap?name= one bitmap {name, data} // GET /api/bitmap?name= one bitmap {name, data}
// POST /api/bitmap store {name, data} // POST /api/bitmap store {name, data}
// POST /api/bitmap/delete {name} // POST /api/bitmap/delete {name}
// POST /api/bitmap/to_icon {name}: becomes an icon (not for bitmaps shown on their own)
// GET /api/icons {icons:[{name, width, height, data}]} (data: rows of
// (width + 7) / 8 bytes in hex, see logic/icon_format.h)
// POST /api/icon store {name, width, height, data}
// POST /api/icon/delete {name}
// POST /api/icon/to_bitmap {name}: becomes a full screen bitmap
// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]} // GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]}
// POST /api/pages/save {name, page} (page format: web/page_json.h) // POST /api/pages/save {name, page} (page format: web/page_json.h)
// POST /api/pages/delete {name} // POST /api/pages/delete {name}
+106
View File
@@ -18,6 +18,7 @@
#include "logic/builtin_fonts.h" #include "logic/builtin_fonts.h"
#include "logic/builtin_pages.h" #include "logic/builtin_pages.h"
#include "logic/font.h" #include "logic/font.h"
#include "logic/icon_format.h"
#include "logic/boot_policy.h" #include "logic/boot_policy.h"
#include "logic/display_controller.h" #include "logic/display_controller.h"
#include "logic/page.h" #include "logic/page.h"
@@ -1536,6 +1537,108 @@ void test_image_element_by_state()
TEST_ASSERT_NOT_NULL(validatePage(page)); TEST_ASSERT_NOT_NULL(validatePage(page));
} }
void test_icon_pixels_and_hex()
{
Icon icon;
icon.width = 11; // 2 bytes per row
icon.height = 3;
TEST_ASSERT_EQUAL(2, iconRowBytes(11));
TEST_ASSERT_EQUAL(6, iconBytes(11, 3));
setIconPixel(icon, 0, 0, true);
setIconPixel(icon, 10, 2, true);
setIconPixel(icon, 11, 0, true); // outside
TEST_ASSERT_TRUE(iconPixel(icon, 0, 0));
TEST_ASSERT_TRUE(iconPixel(icon, 10, 2));
TEST_ASSERT_FALSE(iconPixel(icon, 11, 0));
TEST_ASSERT_FALSE(iconPixel(icon, 1, 0));
char hex[BITMAP_HEX_LENGTH + 1];
iconToHex(icon, hex);
TEST_ASSERT_EQUAL_STRING("800000000020", hex);
Icon back;
TEST_ASSERT_TRUE(iconFromHex(11, 3, hex, back));
TEST_ASSERT_EQUAL(11, back.width);
TEST_ASSERT_EQUAL(3, back.height);
TEST_ASSERT_TRUE(iconPixel(back, 10, 2));
TEST_ASSERT_FALSE(iconPixel(back, 9, 2));
// Bits after the last dot of a row are cleared
TEST_ASSERT_TRUE(iconFromHex(11, 1, "ffff", back));
TEST_ASSERT_EQUAL_HEX8(0xE0, back.data[1]);
TEST_ASSERT_FALSE(iconFromHex(11, 3, "8000", back)); // too short
TEST_ASSERT_FALSE(iconFromHex(11, 3, "80000000002g", back)); // not hex
TEST_ASSERT_FALSE(iconFromHex(0, 3, "", back));
TEST_ASSERT_FALSE(iconFromHex(85, 1, "0000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 20, "0000000000000000000000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 1, nullptr, back));
TEST_ASSERT_TRUE(iconFromHex(84, 19, std::string(BITMAP_HEX_LENGTH, 'f').c_str(), back));
TEST_ASSERT_TRUE(iconPixel(back, 83, 18));
TEST_ASSERT_EQUAL(84, back.width);
TEST_ASSERT_TRUE(validIconSize(1, 1));
TEST_ASSERT_FALSE(validIconSize(84, 0));
}
void test_icon_from_bitmap_keeps_its_dots_in_place()
{
// Like "bg dag": a sun at x 9-19, y 0-10, nothing on the left
uint8_t bitmap[BITMAP_BYTES] = {};
setBitmapPixel(bitmap, 9, 5, true);
setBitmapPixel(bitmap, 19, 0, true);
setBitmapPixel(bitmap, 14, 10, true);
Icon icon;
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(20, icon.width);
TEST_ASSERT_EQUAL(11, icon.height);
uint8_t back[BITMAP_BYTES];
memset(back, 0xAA, sizeof(back));
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// Full screen
memset(bitmap, 0, sizeof(bitmap));
setBitmapPixel(bitmap, 83, 18, true);
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(84, icon.width);
TEST_ASSERT_EQUAL(19, icon.height);
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// No dots: a blank dot
memset(bitmap, 0, sizeof(bitmap));
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(1, icon.width);
TEST_ASSERT_EQUAL(1, icon.height);
TEST_ASSERT_FALSE(iconPixel(icon, 0, 0));
}
void test_page_uses_image()
{
DynamicPage page;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "bg printer");
page.element_count = 3;
page.elements[0].type = ElementType::Image;
strcpy(page.elements[0].text, "plex logo");
page.elements[1].type = ElementType::Image;
strcpy(page.elements[1].text, "weer ");
strcpy(page.elements[1].entity, "weather.home");
page.elements[2].type = ElementType::Text; // texts are no images
strcpy(page.elements[2].text, "hello");
TEST_ASSERT_TRUE(pageUsesImage(page, "BG Printer"));
TEST_ASSERT_TRUE(pageUsesImage(page, "plex logo"));
TEST_ASSERT_FALSE(pageUsesImage(page, "hello"));
TEST_ASSERT_FALSE(pageUsesImage(page, "plex"));
// By state: only the default, unless all states are asked for
TEST_ASSERT_TRUE(pageUsesImage(page, "weer default"));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer sunny"));
TEST_ASSERT_TRUE(pageUsesImage(page, "Weer sunny", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weerbericht", false));
TEST_ASSERT_FALSE(pageUsesImage(page, "sunny", true));
}
void test_moving_page_is_redrawn_when_due() void test_moving_page_is_redrawn_when_due()
{ {
frames.wait = 250; frames.wait = 250;
@@ -2300,6 +2403,9 @@ int main()
RUN_TEST(test_short_text_in_a_window_does_not_scroll); RUN_TEST(test_short_text_in_a_window_does_not_scroll);
RUN_TEST(test_render_asks_for_the_next_second); RUN_TEST(test_render_asks_for_the_next_second);
RUN_TEST(test_image_element_by_state); RUN_TEST(test_image_element_by_state);
RUN_TEST(test_icon_pixels_and_hex);
RUN_TEST(test_icon_from_bitmap_keeps_its_dots_in_place);
RUN_TEST(test_page_uses_image);
RUN_TEST(test_moving_page_is_redrawn_when_due); RUN_TEST(test_moving_page_is_redrawn_when_due);
RUN_TEST(test_schedule_validation); RUN_TEST(test_schedule_validation);
RUN_TEST(test_rule_comparisons); RUN_TEST(test_rule_comparisons);
+1
View File
@@ -33,6 +33,7 @@ SHOTS = [
('schedule', 'schedule', 1150), ('schedule', 'schedule', 1150),
('stopwatch', 'stopwatch', 560), ('stopwatch', 'stopwatch', 560),
('bitmaps', 'bitmaps', 1600), ('bitmaps', 'bitmaps', 1600),
('icons', 'icons', 1900),
('draw', 'draw', 820), ('draw', 'draw', 820),
('settings', 'settings', 1550), ('settings', 'settings', 1550),
('home-assistant', 'ha', 780), ('home-assistant', 'ha', 780),