Public Access
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:
1 parent
72a193130e
commit
41af2944bc
8 files changed
+120
-35
No files matched your search
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user