Public Access
Add export and import of settings, bitmaps, pages and fonts
Export: everything in one JSON file (password and token only when ticked), or a single page (with the bitmaps and fonts it uses) or bitmap. Import: choose the items and the parts of the settings; items are added under a free name unless an existing name is given, pages follow renamed bitmaps and fonts. New endpoints: GET /api/font, GET /api/settings?secrets=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdffa73102
commit
ca8d78ea8a
4 files changed
+119
-5
No files matched your search
@@ -148,7 +148,16 @@ button.d{color:var(--bad)}
|
||||
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
|
||||
<div class="row" style="margin-top:12px"><button class="s" onclick="post('/api/restart').then(()=>msg('Restarting...'))">Restart</button>
|
||||
<button class="s d" onclick="if(confirm('Start the recovery app for 10 minutes?'))post('/api/recovery').then(()=>msg('Starting recovery...'))">Recovery app</button></div>
|
||||
<p class="hint">Firmware updates: <code>pio run -e esp32 -t upload</code></p></div></section>
|
||||
<p class="hint">Firmware updates: <code>pio run -e esp32 -t upload</code></p></div>
|
||||
<div class="card"><h2>Backup</h2>
|
||||
<div class="row"><button class="b" onclick="exportAll()">Export</button>
|
||||
<label><input type="checkbox" id="x_secrets"> include the MQTT password and Home Assistant token</label></div>
|
||||
<h3>Import</h3><div class="row"><label><input type="file" id="i_file" accept=".json,application/json"> Backup file</label></div>
|
||||
<div id="i_box" style="display:none"><div id="i_list"></div>
|
||||
<div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button>
|
||||
<button class="b" onclick="importSel()">Import selected</button></div></div>
|
||||
<p class="hint" id="i_result"></p>
|
||||
<p class="hint">Export: one JSON file with the settings and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.</p></div></section>
|
||||
</main><div id="msg"></div>
|
||||
<script>
|
||||
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
|
||||
@@ -192,13 +201,13 @@ async function loadLists(){const[b,p,f]=await Promise.all([getList('/api/bitmaps
|
||||
pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name)));
|
||||
const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'});
|
||||
r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false)));
|
||||
if(!pg.builtin)r.append(btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
|
||||
if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
|
||||
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
|
||||
pages.appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
|
||||
offs.appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
|
||||
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
|
||||
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
|
||||
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}
|
||||
if(!bm.builtin)r.append(btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}
|
||||
const fl=$('fontlist');fl.innerHTML='';
|
||||
for(const f of fontList)fl.append(mk('label',{textContent:f.name}),mk('span',{},f.height+' dots high'+(f.builtin?', built-in ':' '),f.builtin?'':btn('Delete',()=>delFont(f.name),'d')));
|
||||
if(pe)peBuild()}
|
||||
@@ -304,6 +313,76 @@ function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
|
||||
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()}
|
||||
// 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})],
|
||||
['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,status_led:s.status_led})],
|
||||
['mqtt','Home Assistant MQTT (restarts the ESP32)',s=>{const h=s.home_assistant||{},o={enabled:h.enabled,host:h.host,port:h.port,user:h.user};if(h.password!==undefined)o.password=h.password;return{home_assistant:o}}],
|
||||
['haapi','Home Assistant values (URL, token)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o}}]];
|
||||
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
|
||||
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
|
||||
document.body.append(a);a.click();a.remove()}
|
||||
function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],pages:[],fonts:[]}}
|
||||
async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}}
|
||||
async function exportAll(){const sec=$('x_secrets').checked,out=backup();
|
||||
const[s,b,p,f]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]);
|
||||
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;
|
||||
out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page}));
|
||||
for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name));
|
||||
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
|
||||
// one bitmap, or one page with the bitmaps and fonts it uses (not the built-in ones), without settings
|
||||
async function exportItem(kind,name){const out=backup();
|
||||
if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})}
|
||||
else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page});
|
||||
for(const s of p.page.slots){const b=bitmapList.find(x=>same(x.name,s.bitmap));if(b&&!b.builtin&&!out.bitmaps.some(x=>same(x.name,b.name)))out.bitmaps.push({name:b.name,data:b.data})}
|
||||
for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}}
|
||||
download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)}
|
||||
// import: settings only when ticked; items are added under a free name unless you give them an existing one
|
||||
let imp=null,impRows=[];
|
||||
$('i_file').onchange=async e=>{const f=e.target.files[0];if(!f)return;e.target.value='';
|
||||
try{imp=JSON.parse(await f.text());if(!imp||imp.format!='flipdot-backup')throw 0}catch(err){imp=null;$('i_box').style.display='none';msg('Not a FlipDot backup file',true);return}
|
||||
$('i_result').textContent='';impShow()};
|
||||
function freeName(n,taken){if(!taken(n))return n;for(let i=2;;i++){const s=' '+i,c=n.slice(0,24-s.length).trim()+s;if(!taken(c))return c}}
|
||||
function impShow(){const d=$('i_list');d.innerHTML='';impRows=[];const ha=(imp.settings&&imp.settings.home_assistant)||{};
|
||||
if(imp.settings){d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
|
||||
SGROUPS.forEach(([k,l])=>{const c=mk('input',{type:'checkbox',checked:false}),note=k=='mqtt'&&ha.password!==undefined?' (with password)':k=='haapi'&&ha.token!==undefined?' (with token)':'';
|
||||
c.dataset.key=k;d.append(mk('div',{},mk('label',{},c,' '+l+note)))})}
|
||||
// bitmaps and pages share their names, fonts have their own
|
||||
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)),
|
||||
takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n));
|
||||
const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title}));
|
||||
list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name);
|
||||
const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})};
|
||||
row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})};
|
||||
group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Dynamic pages','p',imp.pages||[]);
|
||||
$('i_box').style.display='block'}
|
||||
function impNote(r){const n=r.i.value.trim(),own=r.kind=='f'?fontList:r.kind=='b'?bitmapList:pageList,other=r.kind=='b'?pageList:r.kind=='p'?bitmapList:[];
|
||||
const replaces=own.some(x=>same(x.name,n)),clash=other.some(x=>same(x.name,n));
|
||||
r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+(r.kind=='b'?'page':'bitmap'):'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
|
||||
r.note.style.color=!n||replaces||clash?'var(--bad)':''}
|
||||
function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)}
|
||||
// fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last
|
||||
async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key));
|
||||
const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return}
|
||||
const errors=[],rename={f:{},b:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...';
|
||||
const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}};
|
||||
for(const kind of['f','b','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim();
|
||||
if(!n){errors.push(r.item.name+': no name');continue}
|
||||
let ok;
|
||||
if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data});
|
||||
else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data});
|
||||
else{const page=JSON.parse(JSON.stringify(r.item.page));(page.slots||[]).forEach(s=>s.bitmap=to('b',s.bitmap)||s.bitmap);
|
||||
(page.elements||[]).forEach(e=>e.font=to('f',e.font)||e.font);ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
|
||||
if(ok)rename[kind][r.item.name.toLowerCase()]=n}
|
||||
await loadLists();
|
||||
if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings));
|
||||
if(body.page)body.page=to('p',body.page)||to('b',body.page)||body.page;if(body.off_page)body.off_page=to('b',body.off_page)||body.off_page;
|
||||
if(body.page&&!pageList.concat(bitmapList).some(x=>same(x.name,body.page))){errors.push('Page "'+body.page+'" not found, not chosen');delete body.page}
|
||||
if(body.off_page&&!bitmapList.some(x=>same(x.name,body.off_page))){errors.push('Off image "'+body.off_page+'" not found, not chosen');delete body.off_page}
|
||||
if(Object.keys(body).length){try{const r=await post('/api/settings',body);settings=r.settings;restart=r.restart;fill();done++}catch(e){errors.push('Settings: '+(e.error||'failed'))}}}
|
||||
loadLists();
|
||||
$('i_result').textContent=done+' imported'+(errors.length?'. Not imported: '+errors.join('; '):'')+(restart?'. Restarting for the MQTT settings...':'');
|
||||
msg(errors.length?'Import finished with problems':'Import finished',errors.length>0)}
|
||||
// editor
|
||||
const ed={bits:new Uint8Array(W*H),c:$('editor'),set(b){this.bits=b;draw(this.c,b)}};ed.set(ed.bits);
|
||||
let painting=null,liveTimer=null,srcImg=null;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "display/page_resources.h"
|
||||
#include "display/page_store.h"
|
||||
#include "logic/bitmap_format.h"
|
||||
#include "logic/builtin_fonts.h"
|
||||
#include "logic/page_render.h"
|
||||
#include "net/ha_api.h"
|
||||
#include "net/home_assistant.h"
|
||||
@@ -381,6 +382,30 @@ static void handleFonts()
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
// An uploaded font's data (for the export)
|
||||
static void handleFontGet()
|
||||
{
|
||||
String name = server.arg("name");
|
||||
Font font = findFont(name.c_str());
|
||||
if (!font.data || findBuiltinFont(name.c_str()))
|
||||
{
|
||||
sendError(404, "Font not found");
|
||||
return;
|
||||
}
|
||||
String hex;
|
||||
hex.reserve(font.size * 2);
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < font.size; i++)
|
||||
{
|
||||
hex += digits[font.data[i] >> 4];
|
||||
hex += digits[font.data[i] & 0x0F];
|
||||
}
|
||||
JsonDocument doc;
|
||||
doc["name"] = name;
|
||||
doc["data"] = hex;
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
static void handleFontSave()
|
||||
{
|
||||
JsonDocument body;
|
||||
@@ -506,6 +531,13 @@ static void handleSettingsGet()
|
||||
{
|
||||
JsonDocument doc;
|
||||
settingsToJson(appSettings(), doc.to<JsonObject>());
|
||||
// The export of the web UI can include them (there is no login: anyone on the WiFi
|
||||
// can use the web UI anyway)
|
||||
if (server.arg("secrets") == "1")
|
||||
{
|
||||
doc["home_assistant"]["password"] = appSettings().home_assistant.password;
|
||||
doc["home_assistant"]["token"] = appSettings().home_assistant.token;
|
||||
}
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
@@ -608,6 +640,7 @@ void webUiBegin()
|
||||
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
|
||||
server.on("/api/pages/preview", HTTP_POST, handlePagePreview);
|
||||
server.on("/api/fonts", HTTP_GET, handleFonts);
|
||||
server.on("/api/font", HTTP_GET, handleFontGet);
|
||||
server.on("/api/fonts/save", HTTP_POST, handleFontSave);
|
||||
server.on("/api/fonts/delete", HTTP_POST, handleFontDelete);
|
||||
server.on("/api/ha/value", HTTP_GET, handleHaValue);
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
// POST /api/pages/delete {name}
|
||||
// POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]}
|
||||
// GET /api/fonts {fonts:[{name, builtin, height}]}
|
||||
// GET /api/font?name= an uploaded font {name, data} (for the export)
|
||||
// POST /api/fonts/save {name, data (hex, format of logic/font.h)}
|
||||
// POST /api/fonts/delete {name}
|
||||
// GET /api/ha/value?entity=&attribute= read a Home Assistant value now {value}
|
||||
// POST /api/page {page: a dynamic page or bitmap name}
|
||||
// POST /api/live show now {data}
|
||||
// POST /api/live/stop
|
||||
// GET /api/settings (the MQTT password and HA token are never sent, only *_set)
|
||||
// GET /api/settings (the MQTT password and HA token only with ?secrets=1, for
|
||||
// the export; otherwise only *_set)
|
||||
// POST /api/settings changed fields; {settings, restart}
|
||||
// POST /api/restart
|
||||
// POST /api/recovery
|
||||
|
||||
Reference in new issue
Block a user