Faster web UI: only changed lists reloaded, icon cache kept, storage usage cached

- Web UI: loadLists(only) fetches only the lists an action changed; saving a
  bitmap, icon or font also reloads the pages that use it
- Icons: a save or delete forgets only that name and updates the name list in
  place instead of listing the directory again
- Storage usage cached until a store writes or deletes a file (at most 60 s):
  /api/status 0.6 s -> 0.08 s
- Version 2.19.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-22 00:13:46 +02:00
1 parent 72a193130e
commit 41af2944bc
8 files changed
+120 -35

No files matched your search

+9
View File
@@ -227,6 +227,15 @@ See README.md for the overview.
takes about 100 ms, and pages are drawn for every frame and every web preview. Writes takes about 100 ms, and pages are drawn for every frame and every web preview. Writes
must go through `saveBitmap()`/`deleteBitmap()`, which clear the cache. Long JSON lists must go through `saveBitmap()`/`deleteBitmap()`, which clear the cache. Long JSON lists
are sent in pieces of about 2 KB (`sendPiece()` in `web_ui.cpp`). are sent in pieces of about 2 KB (`sendPiece()` in `web_ui.cpp`).
- Icons are cached too (`display/icon_store`, all of them). A save or delete only forgets
that name (`forget()`) and updates the cached name list in place (`listAdd()`/
`listRemove()`): clearing it all made the next icon list read every icon again (7 s).
- Storage usage (`bitmapStorageUsage()`, for `/api/status`) is cached: `usedBytes()` walks
the file system (about 0.5 s). Every store calls `bitmapStorageChanged()` after a write
or delete; the cache also expires after 60 s.
- The web UI reloads only the lists that changed (`loadLists(only)`, the ESP32 answers one
request at a time): saving a bitmap, icon or font also reloads the pages when a page
uses it (`pagesUsing()`, `pagesWithFont()`).
- Check the page's JavaScript after changes: extract the `<script>` part and run - Check the page's JavaScript after changes: extract the `<script>` part and run
`node --check` on it (node is installed). `node --check` on it (node is installed).
+1 -1
View File
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000; const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.19.1" #define FIRMWARE_VERSION "2.19.2"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it) // Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28}; const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+23 -2
View File
@@ -62,10 +62,29 @@ bool bitmapStorageAvailable()
return mounted; return mounted;
} }
// usedBytes() walks the whole file system (about 0.5 s), and the web UI asks every 5 s:
// kept until a file is written or deleted, at most USAGE_MAX_AGE
static const uint32_t USAGE_MAX_AGE = 60000;
static bool usage_valid = false;
static uint32_t usage_time = 0;
static size_t usage_used = 0, usage_total = 0;
void bitmapStorageChanged()
{
usage_valid = false;
}
void bitmapStorageUsage(size_t &used, size_t &total) void bitmapStorageUsage(size_t &used, size_t &total)
{ {
used = mounted ? LittleFS.usedBytes() : 0; if (!usage_valid || millis() - usage_time >= USAGE_MAX_AGE)
total = mounted ? LittleFS.totalBytes() : 0; {
usage_used = mounted ? LittleFS.usedBytes() : 0;
usage_total = mounted ? LittleFS.totalBytes() : 0;
usage_valid = true;
usage_time = millis();
}
used = usage_used;
total = usage_total;
} }
std::vector<String> storedNames(const char *dir_path, const char *extension) std::vector<String> storedNames(const char *dir_path, const char *extension)
@@ -175,6 +194,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES; bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close(); file.close();
revision++; revision++;
bitmapStorageChanged();
cache.clear(); cache.clear();
return ok ? nullptr : "Storage full"; return ok ? nullptr : "Storage full";
} }
@@ -188,6 +208,7 @@ const char *deleteBitmap(const char *name)
if (!LittleFS.remove(path(name))) if (!LittleFS.remove(path(name)))
return "Could not delete the file"; return "Could not delete the file";
revision++; revision++;
bitmapStorageChanged();
cache.clear(); cache.clear();
return nullptr; return nullptr;
} }
+2
View File
@@ -24,6 +24,8 @@ bool bitmapStoreBegin();
// (LittleFS is shared with display/page_store and display/font_store) // (LittleFS is shared with display/page_store and display/font_store)
bool bitmapStorageAvailable(); bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total); void bitmapStorageUsage(size_t &used, size_t &total);
// A file in the storage was written or deleted (the stores call it): usage changed
void bitmapStorageChanged();
// The valid names of the files with this extension in a LittleFS directory // 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<String> storedNames(const char *dir, const char *extension);
+2
View File
@@ -97,6 +97,7 @@ const char *saveFont(const char *name, const uint8_t *data, size_t size)
file.close(); file.close();
cache.clear(); cache.clear();
revision++; revision++;
bitmapStorageChanged();
return ok ? nullptr : "Could not write the file (storage full?)"; return ok ? nullptr : "Could not write the file (storage full?)";
} }
@@ -110,5 +111,6 @@ const char *deleteFont(const char *name)
return "Could not delete the file"; return "Could not delete the file";
cache.clear(); cache.clear();
revision++; revision++;
bitmapStorageChanged();
return nullptr; return nullptr;
} }
+44 -7
View File
@@ -62,6 +62,14 @@ struct CachedIcon
static std::map<String, CachedIcon> cache; static std::map<String, CachedIcon> cache;
static const size_t CACHE_MAX = 128; static const size_t CACHE_MAX = 128;
// After a save or delete: only this name (in any case, as it was looked up) is read again.
// Clearing everything made the next list of all icons read each one again (7 s).
static void forget(const char *name)
{
for (auto it = cache.begin(); it != cache.end();)
it = it->first.equalsIgnoreCase(name) ? cache.erase(it) : std::next(it);
}
// Full: first forget the missing names, only then the icons // Full: first forget the missing names, only then the icons
static void makeRoom() static void makeRoom()
{ {
@@ -97,10 +105,35 @@ void iconStoreBegin()
} }
} }
// The sorted names, kept until an icon is saved or deleted (listing takes about a second) // The sorted names, listed once (about a second) and then kept up to date
static std::vector<String> listed; static std::vector<String> listed;
static bool listed_valid = false; static bool listed_valid = false;
static bool nameBefore(const String &a, const String &b)
{
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
static void listAdd(const char *name)
{
// It may replace a built-in one in another case: list again
if (builtinIconName(name))
listed_valid = false;
if (!listed_valid || std::find(listed.begin(), listed.end(), String(name)) != listed.end())
return;
listed.insert(std::lower_bound(listed.begin(), listed.end(), String(name), nameBefore), String(name));
}
static void listRemove(const char *name)
{
// A built-in one stays in the list (from the firmware): list again
if (builtinIconName(name))
listed_valid = false;
auto it = std::find(listed.begin(), listed.end(), String(name));
if (listed_valid && it != listed.end())
listed.erase(it);
}
std::vector<String> listIcons() std::vector<String> listIcons()
{ {
if (listed_valid) if (listed_valid)
@@ -109,8 +142,7 @@ std::vector<String> listIcons()
for (const BuiltinIcon &b : BUILTIN) // not stored (no storage) for (const BuiltinIcon &b : BUILTIN) // not stored (no storage)
if (std::none_of(names.begin(), names.end(), [&b](const String &n) { return strcasecmp(n.c_str(), b.name) == 0; })) if (std::none_of(names.begin(), names.end(), [&b](const String &n) { return strcasecmp(n.c_str(), b.name) == 0; }))
names.push_back(b.name); names.push_back(b.name);
std::sort(names.begin(), names.end(), std::sort(names.begin(), names.end(), nameBefore);
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
listed = names; listed = names;
listed_valid = true; listed_valid = true;
return names; return names;
@@ -194,16 +226,20 @@ const char *saveIcon(const char *name, const Icon &icon)
bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes; bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes;
file.close(); file.close();
revision++; revision++;
cache.clear(); bitmapStorageChanged();
listed_valid = false; forget(name);
if (!ok) if (!ok)
{ {
listed_valid = false;
LittleFS.remove(path(name)); // not half an icon LittleFS.remove(path(name)); // not half an icon
count = -1; count = -1;
return "Storage full"; return "Storage full";
} }
if (is_new) if (is_new)
{
count++; count++;
listAdd(name);
}
return nullptr; return nullptr;
} }
@@ -216,7 +252,8 @@ const char *deleteIcon(const char *name)
if (count > 0) if (count > 0)
count--; count--;
revision++; revision++;
cache.clear(); bitmapStorageChanged();
listed_valid = false; forget(name);
listRemove(name);
return nullptr; return nullptr;
} }
+2
View File
@@ -132,6 +132,7 @@ const char *savePage(const char *name, const DynamicPage &page)
file.close(); file.close();
delete stored; delete stored;
revision++; revision++;
bitmapStorageChanged();
names_valid = false; names_valid = false;
return ok ? nullptr : "Could not write the file (storage full?)"; return ok ? nullptr : "Could not write the file (storage full?)";
} }
@@ -147,6 +148,7 @@ const char *deletePage(const char *name)
if (!removed) if (!removed)
return "Could not delete the file"; return "Could not delete the file";
revision++; revision++;
bitmapStorageChanged();
return nullptr; return nullptr;
} }
+37 -25
View File
@@ -216,7 +216,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
</main><div id="msg"></div> </main><div id="msg"></div>
<script> <script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id); const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false; let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],storageOk=true,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']]; const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)}); tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id} function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
@@ -269,15 +269,27 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})} else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u} if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state) // 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)); function pageImages(page,extra=[]){const u=new Set(),add=n=>n&&u.add(n.toLowerCase()),icons=iconList.concat(extra);(page.slots||[]).forEach(s=>add(s.bitmap));
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text); (page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)}); if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))icons.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
if(e.type!='image')return;if(!e.entity)add(e.text); 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} else if(e.text)bitmapList.concat(icons).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'} function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]); // Pages that must be loaded again (their previews are drawn on the ESP32) when these
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)); // bitmaps or icons change, or when this font changes
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).'; const pagesUsing=(...names)=>pageList.some(p=>{const u=pageImages(p.page,names.map(name=>({name})));return names.some(n=>u.has(n.toLowerCase()))})?['pages']:[];
const pagesWithFont=n=>pageList.some(p=>(p.page.elements||[]).some(e=>same(e.font,n)))?['pages']:[];
// Loads the lists named in `only` again (bitmaps, pages, fonts, schedule, icons; all when
// left out) and redraws them all. The ESP32 answers one request at a time, so after a
// change only what changed is fetched.
async function loadLists(only){const want=k=>!only||only.includes(k),get=(k,p,key)=>want(k)?getList(p,key):null;
const[b,p,f,sc,ic]=await Promise.all([get('bitmaps','/api/bitmaps','bitmaps'),get('pages','/api/pages','pages'),get('fonts','/api/fonts','fonts'),get('schedule','/api/schedule','blocks'),get('icons','/api/icons','icons')]);
if(b){bitmapList=b.bitmaps;bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));storageOk=b.storage}
if(p){pageList=p.pages;pageList.forEach(pg=>pg.bits=fromHex(pg.data))}
if(f)fontList=f.fonts;
if(ic){iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data))}
if(sc&&!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};
$('storage').textContent=storageOk?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps(); const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML=''); ['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page)); offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
@@ -308,15 +320,15 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b} function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits)); function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits));
d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d} d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()} async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists([])}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()} async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists([])}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()} async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists(['bitmaps'])}
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 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(['icons'])}
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 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(['bitmaps','icons'])}
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 toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists(['bitmaps','icons'])}
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 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(['pages'])}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()} async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists(['pages'])}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()} async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists(['fonts'])}
function swText(ms){const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(d?d+'d '+h+':'+p(m):h?h+':'+p(m):m)+':'+p(s%60)} function swText(ms){const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(d?d+'d '+h+':'+p(m):h?h+':'+p(m):m)+':'+p(s%60)}
function swShow(sw){if(!sw)return;$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'');$('swsince').textContent=sw.start?'Counting since '+sw.start.replace('T',' '):sw.ms?'Paused':''} function swShow(sw){if(!sw)return;$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'');$('swsince').textContent=sw.start?'Counting since '+sw.start.replace('T',' '):sw.ms?'Paused':''}
async function stopwatch(action,extra){swShow(await post('/api/stopwatch',Object.assign({action},extra)));if(pe)pePreview()} async function stopwatch(action,extra){swShow(await post('/api/stopwatch',Object.assign({action},extra)));if(pe)pePreview()}
@@ -367,7 +379,7 @@ function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);re
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()} 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} 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,over_manual:true,over_slow_mode:false,skip_slow_mode:false});schedDirty=true;schedBuild()} sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true,over_slow_mode:false,skip_slow_mode:false});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 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 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){}} 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){}}
// page editor: the ESP32 draws the preview and returns the element boxes // page editor: the ESP32 draws the preview and returns the element boxes
@@ -499,7 +511,7 @@ async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0} if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()} async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return} async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()} await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists(['pages'])}
async function peLive(){if(peData)await live(peData)} async function peLive(){if(peData)await live(peData)}
// fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h) // fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h)
let fontFam=null,fontData=null; let fontFam=null,fontData=null;
@@ -527,7 +539,7 @@ function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w} for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w}
draw($('f_canvas'),b)} draw($('f_canvas'),b)}
async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return} async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists()} await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists(['fonts',...pagesWithFont(n)])}
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints // backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})], const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})], ['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
@@ -620,9 +632,9 @@ function edClear(){ed.set(new Uint8Array(W*H));changed()}
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()} function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)} async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return} async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()} await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists(['bitmaps',...pagesUsing(n)])}
async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return} 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()} 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(['icons',...pagesUsing(n)])}
// image conversion: w x h dots, dark parts on // image conversion: w x h dots, dark parts on
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)}; $('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert); ['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
@@ -653,9 +665,9 @@ function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>=
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0])); function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}), d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}} mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r); async function lifeAdd(list){let k=0,bad=0;const added=[];for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}} try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++;added.push(n)}catch(e){bad++}}
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()} if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists(['icons',...pagesUsing(...added)])}
// icon editor: its own size, drawn like the bitmap editor // icon editor: its own size, drawn like the bitmap editor
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas'); 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 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'})}
@@ -678,7 +690,7 @@ $('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert()); ['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()} 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} 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()} 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(['icons',...pagesUsing(n)])}
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))} async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
// test patterns (live mode) // test patterns (live mode)
let testTimer=null; let testTimer=null;