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

+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);