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:
mangavalkandClaude Opus 5.5 committed 2026-10-15 20:26:51 +02:00
1 parent fdffa73102
commit ca8d78ea8a
4 files changed
+119 -5

No files matched your search

+1 -1
View File
@@ -100,7 +100,7 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. | | **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. | | **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. |
| **Home Assistant** | On/off, broker, port, user, password (changing these restarts the ESP32; an empty broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic pages (REST API, plain http; empty URL: `http://<broker>:8123`). | | **Home Assistant** | On/off, broker, port, user, password (changing these restarts the ESP32; an empty broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic pages (REST API, plain http; empty URL: `http://<broker>:8123`). |
| **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. | | **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. Backup: export everything (settings, your bitmaps, pages and fonts; the MQTT password and HA token only when ticked) to one JSON file, and import a file: choose which items and which parts of the settings. Imported items are added (a taken name gets a number) unless you give them an existing name. Pages and bitmaps also have their own Export button. |
**Live mode** shows the image at once, also at night or before the time is known. It **Live mode** shows the image at once, also at night or before the time is known. It
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
+82 -3
View File
@@ -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> <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> <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> <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> </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);
@@ -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))); 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'}); 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))); 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'; 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))); 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))); 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'}); 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')})); 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=''; 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'))); 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()} if(pe)peBuild()}
@@ -304,6 +313,76 @@ function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
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()}
// 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 // editor
const ed={bits:new Uint8Array(W*H),c:$('editor'),set(b){this.bits=b;draw(this.c,b)}};ed.set(ed.bits); 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; let painting=null,liveTimer=null,srcImg=null;
+33
View File
@@ -13,6 +13,7 @@
#include "display/page_resources.h" #include "display/page_resources.h"
#include "display/page_store.h" #include "display/page_store.h"
#include "logic/bitmap_format.h" #include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
#include "logic/page_render.h" #include "logic/page_render.h"
#include "net/ha_api.h" #include "net/ha_api.h"
#include "net/home_assistant.h" #include "net/home_assistant.h"
@@ -381,6 +382,30 @@ static void handleFonts()
sendJson(200, doc); 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() static void handleFontSave()
{ {
JsonDocument body; JsonDocument body;
@@ -506,6 +531,13 @@ static void handleSettingsGet()
{ {
JsonDocument doc; JsonDocument doc;
settingsToJson(appSettings(), doc.to<JsonObject>()); 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); sendJson(200, doc);
} }
@@ -608,6 +640,7 @@ void webUiBegin()
server.on("/api/pages/delete", HTTP_POST, handlePageDelete); server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
server.on("/api/pages/preview", HTTP_POST, handlePagePreview); server.on("/api/pages/preview", HTTP_POST, handlePagePreview);
server.on("/api/fonts", HTTP_GET, handleFonts); 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/save", HTTP_POST, handleFontSave);
server.on("/api/fonts/delete", HTTP_POST, handleFontDelete); server.on("/api/fonts/delete", HTTP_POST, handleFontDelete);
server.on("/api/ha/value", HTTP_GET, handleHaValue); server.on("/api/ha/value", HTTP_GET, handleHaValue);
+3 -1
View File
@@ -16,13 +16,15 @@
// POST /api/pages/delete {name} // POST /api/pages/delete {name}
// POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]} // POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]}
// GET /api/fonts {fonts:[{name, builtin, height}]} // 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/save {name, data (hex, format of logic/font.h)}
// POST /api/fonts/delete {name} // POST /api/fonts/delete {name}
// GET /api/ha/value?entity=&attribute= read a Home Assistant value now {value} // 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/page {page: a dynamic page or bitmap name}
// POST /api/live show now {data} // POST /api/live show now {data}
// POST /api/live/stop // 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/settings changed fields; {settings, restart}
// POST /api/restart // POST /api/restart
// POST /api/recovery // POST /api/recovery