Add dynamic pages with clock, date, Home Assistant values and fonts

The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page
has a background bitmap per time of day and up to 8 elements (clock, date,
Home Assistant value, text), each with position, alignment, font and overlay.
Pages are drawn on the ESP32, also for the web previews.

- Pages tab in the web UI: page editor with drag and drop, font upload
  (TTF/OTF converted in the browser to the FDF1 dot font format)
- Built-in fonts Small, Normal, Large and Full height (generated by
  tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning
- Home Assistant values over the REST API (URL and token in the HA tab)
- Settings version 4: evening settings removed (now time slots of the Clock
  page), old records are converted
- HA Mode dropdown lists the dynamic pages and bitmaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 19:43:59 +02:00
1 parent 1d3b084de1
commit 0411804a2f
49 files changed
+3058 -331

No files matched your search

+26 -13
View File
@@ -15,6 +15,8 @@ struct BuiltinBitmap
static const BuiltinBitmap BUILTIN[] = {
{"Nintendo", nintendo_bitmap_Nintendo_new},
{"Pikachu", pikachu_clock_bitmap},
{"Pikachu yawning", pikachu_yawning_clock_bitmap},
};
static bool mounted = false;
@@ -36,8 +38,9 @@ static String path(const char *name)
bool bitmapStoreBegin()
{
mounted = LittleFS.begin(true);
if (mounted && !LittleFS.exists(DIR))
LittleFS.mkdir(DIR);
for (const char *dir : {DIR, "/pages", "/fonts"})
if (mounted && !LittleFS.exists(dir))
LittleFS.mkdir(dir);
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
return mounted;
}
@@ -53,27 +56,37 @@ void bitmapStorageUsage(size_t &used, size_t &total)
total = mounted ? LittleFS.totalBytes() : 0;
}
std::vector<BitmapInfo> listBitmaps()
std::vector<String> storedNames(const char *dir_path, const char *extension)
{
std::vector<BitmapInfo> list;
for (const BuiltinBitmap &b : BUILTIN)
list.push_back({b.name, true});
std::vector<String> names;
if (!mounted)
return list;
File dir = LittleFS.open(DIR);
return names;
File dir = LittleFS.open(dir_path);
if (!dir)
return names;
for (File file = dir.openNextFile(); file; file = dir.openNextFile())
{
String name = file.name();
int slash = name.lastIndexOf('/');
if (slash >= 0)
name = name.substring(slash + 1);
if (!name.endsWith(".bin"))
if (!name.endsWith(extension))
continue;
name.remove(name.length() - 4);
if (validBitmapName(name.c_str()) && !builtin(name.c_str()))
list.push_back({name, false});
name.remove(name.length() - strlen(extension));
if (validBitmapName(name.c_str()))
names.push_back(name);
}
return names;
}
std::vector<BitmapInfo> listBitmaps()
{
std::vector<BitmapInfo> list;
for (const BuiltinBitmap &b : BUILTIN)
list.push_back({b.name, true});
for (const String &name : storedNames(DIR, ".bin"))
if (!builtin(name.c_str()))
list.push_back({name, false});
return list;
}
+5 -1
View File
@@ -7,7 +7,8 @@
// Full screen bitmaps (84 x 19, see logic/bitmap_format.h): built-in ones from
// display/bitmaps.h and the ones uploaded in the web UI, stored on LittleFS
// (partition "spiffs" in partitions.csv) as /bitmaps/<name>.bin.
// (partition "spiffs" in partitions.csv) as /bitmaps/<name>.bin. Bitmaps and dynamic
// pages share one namespace: app.cpp checks that a new name is not taken.
struct BitmapInfo
{
@@ -20,8 +21,11 @@ const int MAX_BITMAPS = 200;
// Mounts LittleFS (formats it the first time). False without the partition (old flash
// layout): only the built-in bitmaps are available then.
bool bitmapStoreBegin();
// (LittleFS is shared with display/page_store and display/font_store)
bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
// The valid names of the files with this extension in a LittleFS directory
std::vector<String> storedNames(const char *dir, const char *extension);
std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted
+32 -28
View File
@@ -39,34 +39,38 @@ const unsigned char yawning_pikachu_bitmap_yawning_pikachu [] PROGMEM = {
0xfe, 0xff, 0xf7, 0x6f, 0xe0, 0xfe, 0x7f, 0xfb, 0x8f, 0xe0, 0xfe, 0xfe, 0x0b, 0xaf, 0xe0
};
// 'yawning_pikachu_black_border', 35x19px
const unsigned char yawning_pikachu_bitmap_yawning_pikachu_black_border [] PROGMEM = {
0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07,
0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x7f, 0xff, 0xdf, 0x80, 0x7f, 0x73,
0xf9, 0xdf, 0xc0, 0xfe, 0xcf, 0xbe, 0x6f, 0xe0, 0xfd, 0xff, 0xff, 0xf7, 0xe0, 0x01, 0xff, 0x1f,
0xf0, 0x00, 0x03, 0x9e, 0x0f, 0x38, 0x00, 0x03, 0x0e, 0xee, 0x18, 0x00, 0x03, 0x0e, 0xee, 0x18,
0x00, 0x03, 0x9e, 0xe3, 0x38, 0x00, 0x03, 0xff, 0x0d, 0xf8, 0x00, 0x01, 0xff, 0xee, 0xf0, 0x00,
0x01, 0xff, 0xf7, 0x70, 0x00, 0x00, 0x7f, 0xfb, 0x80, 0x00, 0x01, 0xfe, 0x0b, 0xb0, 0x00
// 'Pikachu', 84x19px: Pikachu with an empty thought bubble (background of the Clock page)
const unsigned char pikachu_clock_bitmap[] PROGMEM = {
0x00, 0x00, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xe0, 0x00,
0x3f, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0xff, 0xff, 0xff, 0x80, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0xff, 0xff, 0xff,
0xe0, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0xee, 0x7f, 0x3b, 0xf0, 0x80, 0x00, 0x00, 0x00,
0x01, 0x00, 0x0f, 0xed, 0x3e, 0x9b, 0xf8, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xdc, 0x3e,
0x1d, 0xfc, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xbe, 0x7f, 0x3e, 0xfc, 0x80, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x3f, 0xf7, 0xfe, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x73,
0xff, 0xe7, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xff, 0xc3, 0x00, 0x80, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xff, 0xc3, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
0x73, 0xb6, 0xe7, 0x00, 0x20, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x7f, 0xc9, 0xff, 0x00, 0x41,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xff, 0xfe, 0x01, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xf0, 0x02,
0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xc1, 0xfe, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00,
};
// 'clock thought bubble', 46x19px
const unsigned char clock_thought_bubble_bitmap_clock_thought_bubble [] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x00, 0x10, 0x00, 0x00, 0x00,
0x00, 0x80, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00,
0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40,
0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00,
0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x10, 0x00, 0x00, 0x00, 0x00, 0x80, 0x08, 0x3f,
0xff, 0xff, 0xff, 0x00, 0x10, 0x40, 0x00, 0x00, 0x00, 0x00, 0x21, 0x80, 0x00, 0x00, 0x00, 0x00,
0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00,
0x00, 0x00
};
// 'pikachu_black_border', 35x19px
const unsigned char pikachu_bitmap_pikachu_black_border [] PROGMEM = {
0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07,
0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x73, 0xf9, 0xdf, 0x80, 0x7f, 0x69,
0xf4, 0xdf, 0xc0, 0xfe, 0xe1, 0xf0, 0xef, 0xe0, 0xfd, 0xf3, 0xf9, 0xf7, 0xe0, 0x01, 0xff, 0xbf,
0xf0, 0x00, 0x03, 0x9f, 0xff, 0x38, 0x00, 0x03, 0x0f, 0xfe, 0x18, 0x00, 0x03, 0x0f, 0xfe, 0x18,
0x00, 0x03, 0x9d, 0xb7, 0x38, 0x00, 0x03, 0xfe, 0x4f, 0xf8, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00,
0x01, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x7f, 0xff, 0x80, 0x00, 0x01, 0xfe, 0x0f, 0xf0, 0x00
// 'Pikachu yawning', 84x19px: Pikachu with an empty thought bubble (background of the Clock page)
const unsigned char pikachu_yawning_clock_bitmap[] PROGMEM = {
0x00, 0x00, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xe0, 0x00,
0x3f, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x3f, 0xff, 0xfe, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0xff, 0xff, 0xff, 0x80, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0xff, 0xff, 0xff,
0xe0, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0xef, 0xff, 0xfb, 0xf0, 0x80, 0x00, 0x00, 0x00,
0x01, 0x00, 0x0f, 0xee, 0x7f, 0x3b, 0xf8, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xd9, 0xf7,
0xcd, 0xfc, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1f, 0xbf, 0xff, 0xfe, 0xfc, 0x80, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x3f, 0xe3, 0xfe, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x73,
0xc1, 0xe7, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xdd, 0xc3, 0x00, 0x80, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x61, 0xdd, 0xc3, 0x00, 0x40, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00,
0x73, 0xdc, 0x67, 0x00, 0x20, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x7f, 0xe1, 0xbf, 0x00, 0x41,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xfd, 0xde, 0x00, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xfe, 0xee, 0x01, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0x70, 0x02,
0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xc1, 0x76, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00,
};
+114
View File
@@ -0,0 +1,114 @@
#include "display/font_store.h"
#include <LittleFS.h>
#include <map>
#include "display/bitmap_store.h"
#include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
static const char *DIR = "/fonts";
static const char *EXTENSION = ".fnt";
static uint32_t revision = 0;
// Loaded fonts by name; cleared when a font is saved or deleted
static std::map<String, std::vector<uint8_t>> cache;
static String path(const char *name)
{
return String(DIR) + "/" + name + EXTENSION;
}
std::vector<FontInfo> listFonts()
{
std::vector<FontInfo> list;
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
list.push_back({BUILTIN_FONTS[i].name, true, (uint8_t)fontHeight(builtinFont(BUILTIN_FONTS[i]))});
for (const String &name : storedNames(DIR, EXTENSION))
if (!findBuiltinFont(name.c_str()))
list.push_back({name, false, (uint8_t)fontHeight(findFont(name.c_str()))});
return list;
}
uint32_t fontListRevision()
{
return revision;
}
bool fontExists(const char *name)
{
if (findBuiltinFont(name))
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
Font findFont(const char *name)
{
const BuiltinFont *builtin = findBuiltinFont(name);
if (builtin)
return builtinFont(*builtin);
Font font;
if (!bitmapStorageAvailable() || !validBitmapName(name))
return font;
auto cached = cache.find(name);
if (cached == cache.end())
{
if (cache.size() >= 16)
cache.clear();
std::vector<uint8_t> data;
File file;
if (LittleFS.exists(path(name))) // open() logs an error for a missing file
file = LittleFS.open(path(name), "r");
if (file && file.size() <= FONT_MAX_SIZE)
{
data.resize(file.size());
if (file.read(data.data(), data.size()) != data.size() || !validFont(data.data(), data.size()))
data.clear();
}
if (file)
file.close();
cached = cache.emplace(name, std::move(data)).first; // also remembers a missing font
}
if (!cached->second.empty())
{
font.data = cached->second.data();
font.size = cached->second.size();
}
return font;
}
const char *saveFont(const char *name, const uint8_t *data, size_t size)
{
if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (findBuiltinFont(name))
return "A built-in font has this name";
if (!validFont(data, size))
return "Invalid font data";
if (!bitmapStorageAvailable())
return "No storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && storedNames(DIR, EXTENSION).size() >= (size_t)MAX_FONTS)
return "Too many fonts, delete some first";
File file = LittleFS.open(path(name), "w");
bool ok = file && file.write(data, size) == size;
if (file)
file.close();
cache.clear();
revision++;
return ok ? nullptr : "Could not write the file (storage full?)";
}
const char *deleteFont(const char *name)
{
if (findBuiltinFont(name))
return "Built-in fonts can't be deleted";
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Font not found";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
cache.clear();
revision++;
return nullptr;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "logic/font.h"
// Fonts of the dynamic pages: the built-in ones (logic/builtin_fonts.h) and the ones
// converted from TTF/OTF in the web UI, stored on LittleFS as /fonts/<name>.fnt in the
// format of logic/font.h. Font names have their own namespace.
struct FontInfo
{
String name;
bool builtin;
uint8_t height; // in dots (scaled)
};
const int MAX_FONTS = 20;
// Built-in fonts first, then the stored ones
std::vector<FontInfo> listFonts();
// Changes each time a font is saved or deleted
uint32_t fontListRevision();
bool fontExists(const char *name);
// A font by name: built-in, or loaded from LittleFS and kept in memory. A font without
// data when it is gone. The data stays valid until the next findFont(), saveFont() or
// deleteFont().
Font findFont(const char *name);
// Return nullptr on success, otherwise a message for the user
const char *saveFont(const char *name, const uint8_t *data, size_t size);
const char *deleteFont(const char *name);
+10 -21
View File
@@ -3,33 +3,17 @@
#include <stdio.h>
#include <string.h>
#include "display/bitmaps.h"
#include "display/text.h"
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
static const int CLOCK_X = 3;
FlipdotFrames::FlipdotFrames(BROSE9323 &display) : _display(display)
FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _display(display), _resources(resources)
{
}
void FlipdotFrames::clock(int hour, int minute, bool evening)
void FlipdotFrames::dynamicPage(const LocalTime &time)
{
char time[6];
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
_display.fillScreen(0);
if (evening)
_display.drawBitmap(CLOCK_X, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
else
_display.drawBitmap(CLOCK_X, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
_display.drawBitmap(CLOCK_X + 35, 0, clock_thought_bubble_bitmap_clock_thought_bubble, 46, 19, 1);
_display.setTextSize(1);
_display.setTextWrap(false);
_display.setCursor(CLOCK_X + 43, 4);
_display.print(time);
_display.display();
uint8_t bitmap[BITMAP_BYTES];
renderPage(_dynamic, time, _resources, bitmap);
_drawFullScreen(bitmap);
}
void FlipdotFrames::noWifi(int hour, int minute)
@@ -55,6 +39,11 @@ void FlipdotFrames::off()
_drawFullScreen(_off);
}
void FlipdotFrames::setDynamicPage(const DynamicPage &page)
{
_dynamic = page;
}
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
{
memcpy(_page, bitmap, BITMAP_BYTES);
+7 -3
View File
@@ -3,15 +3,16 @@
#include "BROSE9323.h"
#include "logic/bitmap_format.h"
#include "logic/display_controller.h"
#include "logic/page_render.h"
// Draws the screens on the flipdot display. Each method sends one complete frame.
class FlipdotFrames : public Frames
{
public:
explicit FlipdotFrames(BROSE9323 &display);
FlipdotFrames(BROSE9323 &display, PageResources &resources);
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute, bool evening) override;
// The page set with setDynamicPage(), drawn for this time
void dynamicPage(const LocalTime &time) override;
// "No WiFi" with the time below it
void noWifi(int hour, int minute) override;
// The bitmap set with setPageBitmap()
@@ -21,6 +22,7 @@ public:
// Blank, or the bitmap set with setOffBitmap()
void off() override;
void setDynamicPage(const DynamicPage &page);
void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap);
// nullptr: blank
@@ -28,6 +30,8 @@ public:
private:
BROSE9323 &_display;
PageResources &_resources;
DynamicPage _dynamic;
uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
+22
View File
@@ -0,0 +1,22 @@
#include "display/page_resources.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "net/ha_api.h"
StoredPageResources pageResources;
bool StoredPageResources::background(const char *name, uint8_t *bitmap)
{
return loadBitmap(name, bitmap);
}
Font StoredPageResources::font(const char *name)
{
return findFont(name);
}
const char *StoredPageResources::haValue(const PageElement &element)
{
return haApiValue(element.entity, element.attribute);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "logic/page_render.h"
// Bitmaps from display/bitmap_store, fonts from display/font_store and Home Assistant
// values from net/ha_api, for drawing dynamic pages on the display and in the web UI
class StoredPageResources : public PageResources
{
public:
bool background(const char *name, uint8_t *bitmap) override;
Font font(const char *name) override;
const char *haValue(const PageElement &element) override;
};
extern StoredPageResources pageResources;
+150
View File
@@ -0,0 +1,150 @@
#include "display/page_store.h"
#include <LittleFS.h>
#include "display/bitmap_store.h"
#include "logic/builtin_pages.h"
static const char *DIR = "/pages";
static const char *EXTENSION = ".pg";
// Increase when DynamicPage changes (older files are then ignored)
static const uint16_t VERSION = 1;
struct StoredPage
{
uint16_t version;
uint16_t size;
DynamicPage page;
};
static uint32_t revision = 0;
static String path(const char *name)
{
return String(DIR) + "/" + name + EXTENSION;
}
std::vector<PageInfo> listPages()
{
std::vector<PageInfo> list;
for (int i = 0; i < builtinPageCount(); i++)
list.push_back({builtinPageName(i), true});
for (const String &name : storedNames(DIR, EXTENSION))
if (!builtinPageExists(name.c_str()))
list.push_back({name, false});
return list;
}
uint32_t pageListRevision()
{
return revision;
}
bool builtinPageExists(const char *name)
{
for (int i = 0; i < builtinPageCount(); i++)
if (sameName(builtinPageName(i), name))
return true;
return false;
}
bool pageExists(const char *name)
{
if (builtinPageExists(name))
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
bool loadPage(const char *name, DynamicPage &page)
{
if (builtinPage(name, page))
return true;
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return false;
File file = LittleFS.open(path(name), "r");
if (!file)
return false;
StoredPage *stored = new StoredPage;
bool ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) &&
stored->version == VERSION && stored->size == sizeof(DynamicPage) &&
validatePage(stored->page) == nullptr;
file.close();
if (ok)
page = stored->page;
delete stored;
return ok;
}
const char *savePage(const char *name, const DynamicPage &page)
{
if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (builtinPageExists(name))
return "A built-in page has this name";
const char *error = validatePage(page);
if (error)
return error;
if (!bitmapStorageAvailable())
return "No storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && storedNames(DIR, EXTENSION).size() >= (size_t)MAX_PAGES)
return "Too many pages, delete some first";
StoredPage *stored = new StoredPage;
stored->version = VERSION;
stored->size = sizeof(DynamicPage);
stored->page = page;
File file = LittleFS.open(path(name), "w");
bool ok = file && file.write((const uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage);
if (file)
file.close();
delete stored;
revision++;
return ok ? nullptr : "Could not write the file (storage full?)";
}
const char *deletePage(const char *name)
{
if (builtinPageExists(name))
return "Built-in pages can't be deleted";
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Page not found";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
revision++;
return nullptr;
}
// The first stored page for which `uses` is true
template <typename F> static String findPage(F uses)
{
DynamicPage *page = new DynamicPage;
String found;
for (const String &name : storedNames(DIR, EXTENSION))
if (loadPage(name.c_str(), *page) && uses(*page))
{
found = name;
break;
}
delete page;
return found;
}
String pageUsingBitmap(const char *bitmap)
{
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;
});
}
String pageUsingFont(const char *font)
{
return findPage([font](const DynamicPage &page) {
for (int i = 0; i < page.element_count; i++)
if (sameName(page.elements[i].font, font))
return true;
return false;
});
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "logic/page.h"
// Dynamic pages (logic/page.h): the built-in ones (logic/builtin_pages.h) and the ones
// made in the web UI, stored on LittleFS as /pages/<name>.pg (the struct with a version).
// Pages and bitmaps share one namespace: app.cpp checks that a new name is not taken.
struct PageInfo
{
String name;
bool builtin;
};
const int MAX_PAGES = 50;
// Built-in pages first, then the stored ones
std::vector<PageInfo> listPages();
// Changes each time a page is saved or deleted
uint32_t pageListRevision();
bool pageExists(const char *name);
bool builtinPageExists(const char *name);
bool loadPage(const char *name, DynamicPage &page);
// Return nullptr on success, otherwise a message for the user. savePage() validates the
// page, but does not check that its bitmaps and fonts exist.
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);
String pageUsingFont(const char *font);