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
|
||||
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`).
|
||||
- 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
|
||||
`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_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)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -62,10 +62,29 @@ bool bitmapStorageAvailable()
|
||||
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)
|
||||
{
|
||||
used = mounted ? LittleFS.usedBytes() : 0;
|
||||
total = mounted ? LittleFS.totalBytes() : 0;
|
||||
if (!usage_valid || millis() - usage_time >= USAGE_MAX_AGE)
|
||||
{
|
||||
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)
|
||||
@@ -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;
|
||||
file.close();
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
cache.clear();
|
||||
return ok ? nullptr : "Storage full";
|
||||
}
|
||||
@@ -188,6 +208,7 @@ const char *deleteBitmap(const char *name)
|
||||
if (!LittleFS.remove(path(name)))
|
||||
return "Could not delete the file";
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
cache.clear();
|
||||
return nullptr;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ bool bitmapStoreBegin();
|
||||
// (LittleFS is shared with display/page_store and display/font_store)
|
||||
bool bitmapStorageAvailable();
|
||||
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
|
||||
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();
|
||||
cache.clear();
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
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";
|
||||
cache.clear();
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
return nullptr;
|
||||
}
|
||||
@@ -62,6 +62,14 @@ struct CachedIcon
|
||||
static std::map<String, CachedIcon> cache;
|
||||
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
|
||||
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 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()
|
||||
{
|
||||
if (listed_valid)
|
||||
@@ -109,8 +142,7 @@ std::vector<String> listIcons()
|
||||
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; }))
|
||||
names.push_back(b.name);
|
||||
std::sort(names.begin(), names.end(),
|
||||
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
|
||||
std::sort(names.begin(), names.end(), nameBefore);
|
||||
listed = names;
|
||||
listed_valid = true;
|
||||
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;
|
||||
file.close();
|
||||
revision++;
|
||||
cache.clear();
|
||||
listed_valid = false;
|
||||
bitmapStorageChanged();
|
||||
forget(name);
|
||||
if (!ok)
|
||||
{
|
||||
listed_valid = false;
|
||||
LittleFS.remove(path(name)); // not half an icon
|
||||
count = -1;
|
||||
return "Storage full";
|
||||
}
|
||||
if (is_new)
|
||||
{
|
||||
count++;
|
||||
listAdd(name);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -216,7 +252,8 @@ const char *deleteIcon(const char *name)
|
||||
if (count > 0)
|
||||
count--;
|
||||
revision++;
|
||||
cache.clear();
|
||||
listed_valid = false;
|
||||
bitmapStorageChanged();
|
||||
forget(name);
|
||||
listRemove(name);
|
||||
return nullptr;
|
||||
}
|
||||
@@ -132,6 +132,7 @@ const char *savePage(const char *name, const DynamicPage &page)
|
||||
file.close();
|
||||
delete stored;
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
names_valid = false;
|
||||
return ok ? nullptr : "Could not write the file (storage full?)";
|
||||
}
|
||||
@@ -147,6 +148,7 @@ const char *deletePage(const char *name)
|
||||
if (!removed)
|
||||
return "Could not delete the file";
|
||||
revision++;
|
||||
bitmapStorageChanged();
|
||||
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>
|
||||
<script>
|
||||
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']];
|
||||
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}
|
||||
@@ -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)})})}
|
||||
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)
|
||||
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);
|
||||
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);
|
||||
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+')'}
|
||||
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')]);
|
||||
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));
|
||||
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
|
||||
// Pages that must be loaded again (their previews are drawn on the ESP32) when these
|
||||
// bitmaps or icons change, or when this font changes
|
||||
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();
|
||||
['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));
|
||||
@@ -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 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}
|
||||
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 delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');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()}
|
||||
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()}
|
||||
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 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(['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(['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(['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(['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(['pages'])}
|
||||
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 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()}
|
||||
@@ -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()}
|
||||
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()}
|
||||
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 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
|
||||
@@ -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}
|
||||
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}
|
||||
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)}
|
||||
// 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;
|
||||
@@ -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}
|
||||
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}
|
||||
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
|
||||
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})],
|
||||
@@ -620,9 +632,9 @@ function edClear(){ed.set(new Uint8Array(W*H));changed()}
|
||||
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
|
||||
async function sendLive(){await live(ed.bits)}
|
||||
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}
|
||||
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
|
||||
$('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);
|
||||
@@ -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]));
|
||||
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])))))}}
|
||||
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);
|
||||
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++}}
|
||||
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
|
||||
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++;added.push(n)}catch(e){bad++}}
|
||||
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
|
||||
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'})}
|
||||
@@ -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());
|
||||
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}
|
||||
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))}
|
||||
// test patterns (live mode)
|
||||
let testTimer=null;
|
||||
|
||||
Reference in new issue
Block a user