Rules can leave a manual pick alone, Clock is an editable page, fix HA attributes

- Rules: "also over a page chosen by hand" (on by default); off, a rule
  waits until the manual pick ends. Schedule store version 3, version 2
  records are read with the option on.
- The Clock page is stored at boot from the built-in one and can be
  edited; Reset (deleting it) brings back the original.
- Home Assistant attributes were never found: ArduinoJson 7.4 takes a
  char array key as a literal of its full array length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 22:13:23 +02:00
1 parent ab001e5d18
commit 51b8d87e86
37 files changed
+134 -42

No files matched your search

+9 -1
View File
@@ -335,11 +335,19 @@ const char *appSavePage(const char *name, const DynamicPage &page)
const char *appDeletePage(const char *name)
{
if (scheduleUses(page_schedule, name))
// A stored copy of a built-in page: deleting it resets the page, which stays
bool resets = builtinPageName(name) != nullptr;
if (!resets && scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE;
const char *error = deletePage(name);
if (error)
return error;
if (resets)
{
if (sameName(shown, name))
reloadPage();
return nullptr;
}
if (sameName(settings.page, name))
setChosen(DEFAULT_PAGE);
return nullptr;
+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.10.0"
#define FIRMWARE_VERSION "2.11.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};
+27 -19
View File
@@ -301,14 +301,31 @@ static String path(const char *name)
return String(DIR) + "/" + name + EXTENSION;
}
static bool storedPage(const char *name)
{
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
void pageStoreBegin()
{
DynamicPage *page = new DynamicPage;
for (int i = 0; i < builtinPageCount(); i++)
if (bitmapStorageAvailable() && !storedPage(builtinPageName(i)) && builtinPage(builtinPageName(i), *page))
{
const char *error = savePage(builtinPageName(i), *page);
Serial.printf("Page \"%s\" stored from the original: %s\n", builtinPageName(i), error ? error : "ok");
}
delete page;
}
std::vector<PageInfo> listPages()
{
std::vector<PageInfo> list;
for (int i = 0; i < builtinPageCount(); i++)
list.push_back({builtinPageName(i), true});
if (!storedPage(builtinPageName(i)))
list.push_back({builtinPageName(i), true, false});
for (const String &name : storedNames(DIR, EXTENSION))
if (!builtinPageExists(name.c_str()))
list.push_back({name, false});
list.push_back({name, false, builtinPageName(name.c_str()) != nullptr});
return list;
}
@@ -319,28 +336,21 @@ uint32_t pageListRevision()
bool builtinPageExists(const char *name)
{
for (int i = 0; i < builtinPageCount(); i++)
if (sameName(builtinPageName(i), name))
return true;
return false;
return builtinPageName(name) != nullptr && !storedPage(name);
}
bool pageExists(const char *name)
{
if (builtinPageExists(name))
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
return builtinPageName(name) != nullptr || storedPage(name);
}
bool loadPage(const char *name, DynamicPage &page)
{
if (builtinPage(name, page))
return true;
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return false;
if (!storedPage(name))
return builtinPage(name, page);
File file = LittleFS.open(path(name), "r");
if (!file)
return false;
return builtinPage(name, page);
bool ok = false;
size_t size = file.size();
if (size == sizeof(StoredPage))
@@ -401,15 +411,13 @@ bool loadPage(const char *name, DynamicPage &page)
delete stored;
}
file.close();
return ok;
return ok || builtinPage(name, page);
}
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;
@@ -435,7 +443,7 @@ 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)))
if (!storedPage(name))
return "Page not found";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
+9 -2
View File
@@ -12,16 +12,23 @@
struct PageInfo
{
String name;
bool builtin;
bool builtin; // the original from logic/builtin_pages (no stored page of that name)
bool original; // a stored page that replaces a built-in one: deleting it brings that back
};
const int MAX_PAGES = 50;
// Built-in pages first, then the stored ones
// At boot: stores a copy of each built-in page that is not stored (the Clock page), so it
// can be edited like the others. The built-in one stays the fallback: deleting the stored
// copy resets it (it is stored again at the next boot).
void pageStoreBegin();
// Built-in pages without a stored copy 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);
// A built-in page that no stored page replaces
bool builtinPageExists(const char *name);
bool loadPage(const char *name, DynamicPage &page);
+8
View File
@@ -16,6 +16,14 @@ const char *builtinPageName(int index)
return index >= 0 && index < builtinPageCount() ? NAMES[index] : nullptr;
}
const char *builtinPageName(const char *name)
{
for (const char *n : NAMES)
if (name && sameName(n, name))
return n;
return nullptr;
}
static void clockPage(DynamicPage &page)
{
page = DynamicPage();
+4 -2
View File
@@ -2,8 +2,8 @@
#include "logic/page.h"
// The built-in dynamic pages. They can't be changed or deleted; the web UI saves an edited
// copy under a new name.
// The original dynamic pages. display/page_store stores a copy of each at boot that can be
// edited; the original is the fallback (deleting the copy resets the page).
//
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of
// the old firmware; also the default page)
@@ -12,5 +12,7 @@
int builtinPageCount();
const char *builtinPageName(int index);
// The built-in name as it is written, nullptr when it is no built-in page
const char *builtinPageName(const char *name);
// False when there is no built-in page with this name (compared without case)
bool builtinPage(const char *name, DynamicPage &page);
+1
View File
@@ -64,6 +64,7 @@ struct TriggerRule
uint16_t seconds = 30; // AddToRotation: time in the rotation; ForSeconds: how long
uint16_t interval = 10; // read the entity this often (s)
uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule)
bool over_manual = true; // also over a page picked by hand (false: it waits until the pick ends)
};
struct PageSchedule
+3 -2
View File
@@ -106,7 +106,7 @@ PageChoice PageSelector::update(int minute, uint32_t now)
_rule_timer[i] = false;
bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i])) &&
ruleInBlock(r, _block);
ruleInBlock(r, _block) && (r.over_manual || !_picked);
if (takes_over)
{
choice.page = r.page;
@@ -138,7 +138,8 @@ PageChoice PageSelector::update(int minute, uint32_t now)
for (int i = 0; i < rule_count; i++)
{
const TriggerRule &r = _schedule->rules[i];
if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block))
if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block) &&
(r.over_manual || !_picked))
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
}
+2 -1
View File
@@ -6,7 +6,8 @@
// Decides which page or bitmap is shown, from (highest priority first):
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be
// limited to some schedule blocks (TriggerRule::blocks)
// limited to some schedule blocks (TriggerRule::blocks), and can leave a manual pick
// alone (TriggerRule::over_manual false: also its AddToRotation page)
// 2. a manual pick (web UI, Home Assistant "Mode"), until the next schedule block starts
// 3. the schedule: the pages of the current block rotate, plus the pages of active
// AddToRotation rules
+2
View File
@@ -7,6 +7,7 @@
#include "app.h"
#include "config.h"
#include "display/bitmap_store.h"
#include "display/page_store.h"
#include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h"
#include "net/ha_api.h"
@@ -33,6 +34,7 @@ void setup()
BootMode boot_mode = firmwareGuardBegin();
bitmapStoreBegin();
pageStoreBegin(); // the Clock page as a page that can be edited
appLoadSettings();
if (boot_mode == BootMode::Normal)
+2 -2
View File
@@ -99,7 +99,7 @@ static const char *fetch(const Request &r, char *value, size_t value_size)
JsonDocument filter;
if (r.attribute[0])
filter["attributes"][r.attribute] = true;
filter["attributes"][(const char *)r.attribute] = true; // a char array would be taken as a literal of 32 characters
else
filter["state"] = true;
JsonDocument doc;
@@ -110,7 +110,7 @@ static const char *fetch(const Request &r, char *value, size_t value_size)
JsonVariantConst v = doc["state"];
if (r.attribute[0])
v = doc["attributes"][r.attribute];
v = doc["attributes"][(const char *)r.attribute];
if (v.isNull())
return "Unknown attribute";
String text;
+1 -1
View File
@@ -97,7 +97,7 @@ static std::vector<String> pageNames()
{
std::vector<String> names, stored;
for (const PageInfo &p : listPages())
(p.builtin ? names : stored).push_back(p.name);
(p.builtin || p.original ? names : stored).push_back(p.name);
std::sort(stored.begin(), stored.end(), lessName);
names.insert(names.end(), stored.begin(), stored.end());
std::vector<String> bitmaps = bitmapNames();
+12 -4
View File
@@ -6,7 +6,10 @@
static const char *NAMESPACE = "flipdot";
static const char *KEY = "schedule";
// Increase when PageSchedule changes; older records are ignored unless converted below
static const uint16_t VERSION = 2;
static const uint16_t VERSION = 3;
// Version 2: the same layout, but TriggerRule::over_manual was padding (set to true)
static const uint16_t VERSION_WITHOUT_OVER_MANUAL = 2;
static_assert(sizeof(TriggerRule) == 164, "version 2 records are only read when TriggerRule kept its size");
struct StoredSchedule
{
@@ -52,7 +55,7 @@ static void convert(const Schedule &old, PageSchedule &s)
s.enabled = old.enabled;
s.block_count = old.block_count;
memcpy(s.blocks, old.blocks, sizeof(s.blocks));
s.rule_count = old.rule_count;
s.rule_count = old.rule_count; // over_manual: the default (true), like before
for (int i = 0; i < TRIGGER_RULES; i++)
{
const Rule &o = old.rules[i];
@@ -81,9 +84,14 @@ void loadSchedule(PageSchedule &schedule)
{
StoredSchedule *stored = new StoredSchedule;
if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) &&
stored->version == VERSION && stored->size == sizeof(PageSchedule) &&
validateSchedule(stored->schedule) == nullptr)
(stored->version == VERSION || stored->version == VERSION_WITHOUT_OVER_MANUAL) &&
stored->size == sizeof(PageSchedule) && validateSchedule(stored->schedule) == nullptr)
{
if (stored->version == VERSION_WITHOUT_OVER_MANUAL)
for (TriggerRule &r : stored->schedule.rules)
r.over_manual = true;
schedule = stored->schedule;
}
delete stored;
}
else if (length == sizeof(v1::Stored))
+2
View File
@@ -59,6 +59,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
o["mode"] = MODES[(int)r.mode];
o["seconds"] = r.seconds;
o["interval"] = r.interval;
o["over_manual"] = r.over_manual;
JsonArray in_blocks = o["blocks"].to<JsonArray>();
for (int b = 0; b < s.block_count; b++)
if ((r.blocks >> b) & 1)
@@ -111,6 +112,7 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
return "Rule text too long";
r.seconds = constrain(o["seconds"] | 30, 0, 65535);
r.interval = constrain(o["interval"] | 10, 0, 65535);
r.over_manual = o["over_manual"] | true;
// The blocks by their start time (none: always)
for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>())
{
+6 -3
View File
@@ -101,7 +101,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and (when ticked) over a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div>
<button class="b" onclick="scSave()">Save schedule and rules</button></section>
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
@@ -274,7 +274,7 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name)));
const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false)));
if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),pg.original?btn('Reset',()=>resetPage(pg.name),'d'):btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
const u=used.has(bm.name.toLowerCase());
$(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
@@ -303,6 +303,7 @@ async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api
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 resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();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)}
@@ -339,6 +340,8 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
btn('Test',async()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')),
mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:ru.over_manual!==false});c.onchange=()=>{ru.over_manual=c.checked;schedDirty=true};
return mk('label',{},c,' also over a page chosen by hand')})(),mk('span',{className:'hint',textContent:'(off: it waits until the chosen page ends: the next block or Back to schedule)'})),
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}),
mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))});
@@ -346,7 +349,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10});schedDirty=true;schedBuild()}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
+2
View File
@@ -396,6 +396,8 @@ static void handlePages()
JsonDocument doc;
doc["name"] = info.name;
doc["builtin"] = info.builtin;
if (info.original)
doc["original"] = true; // Delete resets it
doc["data"] = hex;
pageToJson(*page, doc["page"].to<JsonObject>());
String item;