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

+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/schedule.*` | When the display is on (night, clock, standby), from the settings |
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts |
| `logic/icon_format.*` | Icons: small bitmaps of their own size, to and from full screen bitmaps |
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) |
| `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) |
| `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing |
@@ -40,6 +41,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
| **`display/`** | **What is drawn** |
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
| `display/icon_store.*` | Icons (small images for pages) on LittleFS |
| `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
| `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 |
+185 -3
View File
@@ -7,6 +7,7 @@
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/icon_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "logic/page_selector.h"
@@ -274,6 +275,8 @@ const char *appSaveBitmap(const char *name, const uint8_t *bitmap)
{
if (pageExists(name))
return "A page has this name";
if (iconExists(name))
return "An icon has this name";
const char *error = saveBitmap(name, bitmap);
if (error)
return error;
@@ -297,7 +300,7 @@ const char *appDeleteBitmap(const char *name)
{
if (scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE;
String user = pageUsingBitmap(name);
String user = pageUsingImage(name);
if (user.length())
return usedBy(user);
const char *error = deleteBitmap(name);
@@ -314,9 +317,11 @@ const char *appSavePage(const char *name, const DynamicPage &page)
{
if (bitmapExists(name))
return "A bitmap has this name";
if (iconExists(name))
return "An icon has this name";
for (int i = 0; i < page.slot_count; i++)
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap))
return "Background bitmap not found";
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap) && !iconExists(page.slots[i].bitmap))
return "Background bitmap or icon not found";
for (int i = 0; i < page.element_count; i++)
if (!fontExists(page.elements[i].font))
return "Font not found";
@@ -340,6 +345,183 @@ const char *appDeletePage(const char *name)
return nullptr;
}
const char *appSaveIcon(const char *name, const Icon &icon)
{
if (pageExists(name))
return "A page has this name";
if (bitmapExists(name))
return "A bitmap has this name";
const char *error = saveIcon(name, icon);
if (error)
return error;
reloadPage(); // the page may use it
return nullptr;
}
const char *appDeleteIcon(const char *name)
{
String user = pageUsingImage(name);
if (user.length())
return usedBy(user);
const char *error = deleteIcon(name);
if (error)
return error;
reloadPage(); // an image by state may have shown it
return nullptr;
}
// Why the bitmap can't become an icon (it is shown on its own), or nullptr
static const char *shownOnItsOwn(const char *name)
{
if (builtinBitmap(name))
return "Built-in bitmaps can't become icons";
if (scheduleUses(page_schedule, name))
return "Shown by the schedule or a rule: change that first";
if (sameName(settings.page, name))
return "It is the chosen page: choose another one first";
if (sameName(settings.off_page, name))
return "It is the off image: choose another one first";
return nullptr;
}
// Without redrawing: the pages look the same
static const char *moveToIcon(const char *name)
{
uint8_t bitmap[BITMAP_BYTES];
if (!loadBitmap(name, bitmap))
return "Bitmap not found";
Icon icon;
iconFromBitmap(bitmap, icon);
const char *error = saveIcon(name, icon);
if (error)
return error;
error = deleteBitmap(name);
if (error)
deleteIcon(name); // only one of them
return error;
}
const char *appBitmapToIcon(const char *name)
{
const char *error = shownOnItsOwn(name);
if (error)
return error;
error = moveToIcon(name);
if (error)
return error;
reloadPage();
return nullptr;
}
const char *appIconToBitmap(const char *name)
{
Icon icon;
if (!loadIcon(name, icon))
return "Icon not found";
uint8_t bitmap[BITMAP_BYTES];
iconToBitmap(icon, bitmap);
const char *error = saveBitmap(name, bitmap);
if (error)
return error;
error = deleteIcon(name);
if (error)
{
deleteBitmap(name); // only one of them
return error;
}
reloadPage();
return nullptr;
}
// The one-time move, in steps over many loop() passes: LittleFS is slow (listing a
// directory of 40 files takes about a second), and loop() must never get near the
// watchdog. Each pass works for about MIGRATION_STEP_MS.
enum class Migration
{
Start,
Pages, // which candidates the pages use (one page after the other)
Move,
Done,
};
static const uint32_t MIGRATION_STEP_MS = 1000;
static Migration migration = Migration::Start;
static std::vector<String> migration_bitmaps; // uploaded bitmaps not shown on their own
static std::vector<bool> migration_used;
static std::vector<String> migration_pages;
static size_t migration_next = 0;
static int migration_moved = 0;
static uint32_t migration_started = 0;
void appMigrateIcons()
{
if (migration == Migration::Done)
return;
uint32_t pass = millis();
auto time_left = [pass]() { return millis() - pass < MIGRATION_STEP_MS; };
if (migration == Migration::Start)
{
if (iconMigrationDone())
{
migration = Migration::Done;
return;
}
migration_started = pass;
for (const BitmapInfo &b : listBitmaps())
if (!b.builtin && !shownOnItsOwn(b.name.c_str()))
migration_bitmaps.push_back(b.name);
migration_used.assign(migration_bitmaps.size(), false);
for (const PageInfo &p : listPages())
migration_pages.push_back(p.name);
migration_next = 0;
migration = Migration::Pages;
return;
}
if (migration == Migration::Pages)
{
DynamicPage *page = new DynamicPage;
while (migration_next < migration_pages.size() && time_left())
if (loadPage(migration_pages[migration_next++].c_str(), *page))
for (size_t i = 0; i < migration_bitmaps.size(); i++)
if (pageUsesImage(*page, migration_bitmaps[i].c_str(), true))
migration_used[i] = true;
delete page;
if (migration_next >= migration_pages.size())
{
migration_next = 0;
migration = Migration::Move;
}
return;
}
while (migration_next < migration_bitmaps.size() && time_left())
{
size_t i = migration_next++;
const char *name = migration_bitmaps[i].c_str();
// Checked again: the web UI may have changed something in the meantime
if (!migration_used[i] || shownOnItsOwn(name))
continue;
const char *error = moveToIcon(name);
if (error)
Serial.printf("Bitmap \"%s\" stays a bitmap: %s\n", name, error);
else
migration_moved++;
}
if (migration_next < migration_bitmaps.size())
return;
setIconMigrationDone();
Serial.printf("%d bitmaps that only pages use became icons (%lu ms)\n", migration_moved,
(unsigned long)(millis() - migration_started));
migration_bitmaps.clear();
migration_used.clear();
migration_pages.clear();
migration = Migration::Done;
if (migration_moved)
reloadPage();
}
const char *appSaveFont(const char *name, const uint8_t *data, size_t size)
{
const char *error = saveFont(name, data, size);
+13 -3
View File
@@ -7,6 +7,7 @@
#include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h"
#include "logic/icon_format.h"
#include "logic/local_time.h"
#include "logic/loop_stats.h"
#include "logic/page.h"
@@ -48,11 +49,20 @@ void appPageLoop(bool time_valid, const LocalTime &time);
// Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
const char *appSelectOffPage(const char *name);
// Bitmaps, dynamic pages and fonts: stored, and the display redrawn when it shows them.
// Bitmaps and pages share one namespace; what a page uses can't be deleted. All return
// nullptr or an error message.
// Bitmaps, icons, dynamic pages and fonts: stored, and the display redrawn when it shows
// them. Bitmaps, icons and pages share one namespace; what a page uses can't be deleted.
// All return nullptr or an error message.
const char *appSaveBitmap(const char *name, const uint8_t *bitmap);
const char *appDeleteBitmap(const char *name);
const char *appSaveIcon(const char *name, const Icon &icon);
const char *appDeleteIcon(const char *name);
// A bitmap becomes an icon (cut to its dots from the top left, so pages look the same) and
// the other way around; not for bitmaps that are shown on their own
const char *appBitmapToIcon(const char *name);
const char *appIconToBitmap(const char *name);
// Once: the uploaded bitmaps that only pages use become icons. Call from loop(); returns at
// once when it is done.
void appMigrateIcons();
const char *appSavePage(const char *name, const DynamicPage &page);
const char *appDeletePage(const char *name);
const char *appSaveFont(const char *name, const uint8_t *data, size_t size);
+1 -1
View File
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.9.0"
#define FIRMWARE_VERSION "2.10.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+6 -1
View File
@@ -51,7 +51,7 @@ static String path(const char *name)
bool bitmapStoreBegin()
{
mounted = LittleFS.begin(true);
for (const char *dir : {DIR, "/pages", "/fonts"})
for (const char *dir : {DIR, "/pages", "/fonts", "/icons"})
if (mounted && !LittleFS.exists(dir))
LittleFS.mkdir(dir);
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
@@ -108,6 +108,11 @@ uint32_t bitmapListRevision()
return revision;
}
bool builtinBitmap(const char *name)
{
return builtin(name) != nullptr;
}
bool bitmapExists(const char *name)
{
if (builtin(name))
+2
View File
@@ -31,6 +31,8 @@ std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted
uint32_t bitmapListRevision();
bool bitmapExists(const char *name);
// Without reading the storage
bool builtinBitmap(const char *name);
bool loadBitmap(const char *name, uint8_t *bitmap);
// Return nullptr on success, otherwise a message for the user
+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/font_store.h"
#include "display/icon_store.h"
#include "net/ha_api.h"
Stopwatch stopwatch;
@@ -11,7 +12,13 @@ StoredPageResources pageResources(stopwatch);
bool StoredPageResources::background(const char *name, uint8_t *bitmap)
{
return loadBitmap(name, bitmap);
if (loadBitmap(name, bitmap))
return true;
Icon icon;
if (!loadIcon(name, icon))
return false;
iconToBitmap(icon, bitmap); // at the top left: images are drawn from their x, y
return true;
}
Font StoredPageResources::font(const char *name)
+1 -1
View File
@@ -3,7 +3,7 @@
#include "logic/page_render.h"
#include "logic/stopwatch.h"
// Bitmaps from display/bitmap_store, fonts from display/font_store, Home Assistant values
// Bitmaps and icons from display/bitmap_store and display/icon_store, fonts from display/font_store, Home Assistant values
// from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
// web UI
class StoredPageResources : public PageResources
+2 -7
View File
@@ -458,14 +458,9 @@ template <typename F> static String findPage(F uses)
return found;
}
String pageUsingBitmap(const char *bitmap)
String pageUsingImage(const char *name)
{
return findPage([bitmap](const DynamicPage &page) {
for (int i = 0; i < page.slot_count; i++)
if (sameName(page.slots[i].bitmap, bitmap))
return true;
return false;
});
return findPage([name](const DynamicPage &page) { return pageUsesImage(page, name); });
}
String pageUsingFont(const char *font)
+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 *deletePage(const char *name);
// The first stored page that uses the bitmap (as background) or the font; empty if none
String pageUsingBitmap(const char *bitmap);
// The first stored page that uses the bitmap or icon (logic/page.h pageUsesImage) or the
// font; empty if none
String pageUsingImage(const char *name);
String pageUsingFont(const char *font);
+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 <stdlib.h>
#include <string.h>
#include <strings.h>
static const uint16_t MINUTES_PER_DAY = 24 * 60;
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
@@ -70,6 +71,31 @@ bool elementUsesHa(const PageElement &element)
}
}
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
{
for (int i = 0; i < page.slot_count; i++)
if (sameName(page.slots[i].bitmap, name))
return true;
for (int i = 0; i < page.element_count; i++)
{
const PageElement &e = page.elements[i];
if (e.type != ElementType::Image)
continue;
if (!e.entity[0])
{
if (sameName(e.text, name))
return true;
continue;
}
size_t start = strlen(e.text);
if (strncasecmp(name, e.text, start) != 0)
continue;
if (by_state ? name[start] != '\0' : strcasecmp(name + start, "default") == 0)
return true;
}
return false;
}
bool validEntityId(const char *entity)
{
if (entity == nullptr)
+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)
bool elementUsesHa(const PageElement &element);
// Whether the page uses this bitmap or icon (compared without case): as a background, as
// a fixed image, or as the "default" image of an image with an entity. by_state: also the
// names an image with an entity may show (its name start + any state).
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state = false);
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
// {plex_user} may be in them
bool validEntityId(const char *entity);
+3
View File
@@ -81,6 +81,9 @@ void loop()
homeAssistantLoop();
webUiLoop();
appLoop();
// Once the firmware can't be rolled back anymore (the old one doesn't know icons)
if (!firmwareGuardPending())
appMigrateIcons();
// Home Assistant values of the shown page (read by a task, never blocks)
if (haApiLoop(wifiConnected()))
controller.pageChanged();
+5
View File
@@ -138,6 +138,11 @@ void firmwareGuardStopWatchdog()
watchdog_running = false;
}
bool firmwareGuardPending()
{
return firmware_pending;
}
void firmwareGuardLoop(bool wifi_connected)
{
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
void firmwareGuardLoop(bool wifi_connected);
// A new OTA firmware that is not marked as good yet (it can still be rolled back)
bool firmwareGuardPending();
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
const char *firmwareGuardStatus();
+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)}
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
#f_canvas{width:100%;background:#000;border-radius:8px}
#ic_canvas{max-width:100%;height:auto;display:block;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
.icbox{height:64px;display:flex;align-items:center;justify-content:center;background:#000;border-radius:4px}
.item .icbox canvas{width:auto;height:auto;max-width:100%;max-height:64px;border-radius:0}
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px}
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
@@ -60,7 +63,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<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>
<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="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>
@@ -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>
<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>
<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>
<label><input type="checkbox" id="autolive"> live while drawing</label>
<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>
<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>
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button>
<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">
<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>
<button class="b" onclick="importSel()">Import selected</button></div></div>
<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>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],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']];
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'],['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)});
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)}
@@ -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
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 draw(c,b){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()}}
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()}}
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
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){}
@@ -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);
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}
// 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+')'}
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')]);
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));
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;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).';
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='');
@@ -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)));
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')}));
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));
schedBuild();
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 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 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 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)}
@@ -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 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}
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;
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.'}))}
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 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()}};
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));
@@ -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})));
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');
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)));
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})),
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'})),
@@ -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 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()}
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 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');
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));
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+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
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 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();
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});
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 if(kind=='icon')out.icons.push(icItem(iconList.find(x=>same(x.name,name))));
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))}}
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
@@ -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)'}));
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)')))}
// bitmaps 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)),
// bitmaps, icons and pages share their names, fonts have their own
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));
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);
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))})};
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'}
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:[];
const replaces=own.some(x=>same(x.name,n)),clash=other.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+')':'');
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=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 '+words[clash]:'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
r.note.style.color=!n||replaces||clash?'var(--bad)':''}
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
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 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}};
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}
let ok;
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{const page=JSON.parse(JSON.stringify(r.item.page));(page.slots||[]).forEach(s=>s.bitmap=to('b',s.bitmap)||s.bitmap);
(page.elements||[]).forEach(e=>e.font=to('f',e.font)||e.font);ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
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});
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}
await loadLists();
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 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()}
// 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)};
['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');
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.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}
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);
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;
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}}}
ed.set(b);changed()}
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 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}
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];
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;
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}}}
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)
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]=
+73
View File
@@ -10,6 +10,7 @@
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/icon_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "logic/bitmap_format.h"
@@ -303,6 +304,73 @@ static void handleBitmapDelete()
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
// ---------------------------------------------------------------------------
@@ -751,6 +819,11 @@ void webUiBegin()
server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
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/save", HTTP_POST, handlePageSave);
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
+7 -1
View File
@@ -1,7 +1,7 @@
#pragma once
// 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.
//
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
@@ -11,6 +11,12 @@
// GET /api/bitmap?name= one bitmap {name, data}
// POST /api/bitmap store {name, data}
// 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)}]}
// POST /api/pages/save {name, page} (page format: web/page_json.h)
// POST /api/pages/delete {name}