Files
Flipdot-display/src/esp32/web/web_page.h
T

537 lines
57 KiB
C++
Raw Normal View History

2026-10-13 01:52:34 +02:00
#pragma once
#include <pgmspace.h>
// The web UI: one page, the browser does the image work (conversion, editor, font
// conversion). Dynamic pages are drawn by the ESP32 (/api/pages/preview), so the preview
// is exactly what the display shows. API: see web/web_ui.h. Bitmaps are 84 x 19, as 418
// hex digits in drawBitmap format.
2026-10-13 01:52:34 +02:00
static const char WEB_PAGE[] PROGMEM = R"rawliteral(<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FlipDot Display</title>
<style>
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b;--sel:#4da3ff}
2026-10-13 01:52:34 +02:00
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--text)}
header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}
header h1{font-size:18px;margin:0}#statusline{color:var(--dim);font-size:13px}
main{max-width:900px;margin:0 auto;padding:12px 16px}
.mirror{background:#000;border-radius:10px;padding:10px}.mirror canvas{width:100%;display:block}
#mirrorinfo{color:var(--dim);font-size:13px;margin:6px 2px 12px}
nav{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
nav button{background:var(--card);border:1px solid var(--line);color:var(--text);padding:8px 12px;border-radius:8px;cursor:pointer}
nav button.on{border-color:var(--accent);color:var(--accent)}
section{display:none}section.on{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
h2{font-size:15px;margin:0 0 10px}h3{font-size:13px;margin:14px 0 6px;color:var(--dim)}
2026-10-13 01:52:34 +02:00
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
.item{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer}
.item.cur{border-color:var(--accent)}.item canvas{width:100%;background:#000;border-radius:4px}
.item .name{font-size:13px;margin:6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .tag{color:var(--dim);font-size:11px}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0}
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px;max-width:100%}
2026-10-13 01:52:34 +02:00
input[type=checkbox]{transform:scale(1.2)}input[type=range]{padding:0}
button.b{background:var(--accent);color:#000;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 10px;cursor:pointer}
button.d{color:var(--bad)}
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
#f_canvas{width:100%;background:#000;border-radius:8px}
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px}
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
2026-10-13 01:52:34 +02:00
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
.hint{color:var(--dim);font-size:12px}#msg{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px;display:none}
</style></head><body>
<header><h1>FlipDot Display</h1><span id="statusline">connecting...</span></header>
<main>
<div class="mirror"><canvas id="mirror" width="840" height="190"></canvas></div>
<div id="mirrorinfo"></div>
<nav id="tabs"></nav>
<section id="t-show"><div class="card"><h2>What to show during the day</h2>
<div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="row"><span id="shownnow" class="hint"></span><button class="s" id="backbtn" style="display:none" onclick="backToSchedule()">Back to schedule</button></div>
2026-10-13 01:52:34 +02:00
<div class="grid" id="pages"></div>
<details id="pages_used_box"><summary></summary><div class="grid" id="pages_used"></div></details>
<p class="hint">The display schedule (Settings) decides when the display is on; the Schedule tab what it shows when. With a schedule, a page chosen here (or with the Home Assistant "Mode" dropdown) is shown until the next block starts.</p></div>
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<details id="offpages_used_box"><summary></summary><div class="grid" id="offpages_used"></div></details>
<p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</p></div></section>
2026-10-13 01:52:34 +02:00
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
<p class="hint">A background per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.</p></div>
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
<label>Preview at <input type="time" id="pe_time"></label></div>
<canvas id="pe_canvas" width="840" height="190"></canvas>
<p class="hint" id="pe_err"></p>
<h3>Background</h3><div id="pe_slots"></div>
<div class="row"><button class="s" onclick="peAddSlot()">Add time slot</button></div>
<p class="hint">Each background is shown from its time until the next one; the last one continues after midnight.</p>
<h3>Elements</h3><div id="pe_elems"></div>
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
<button class="s" onclick="peAdd('image')">+ Image</button></div>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type &deg; for degrees. A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
<h3>Add a font</h3>
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
<div class="row" id="f_opts" style="display:none">
<label>Height <input type="number" id="f_h" min="4" max="19" value="10" style="width:4.5em"> dots</label>
<label>Size <input type="number" id="f_size" min="4" max="80" value="12" style="width:4.5em"> px</label>
<label>Baseline <input type="number" id="f_base" min="1" max="19" value="8" style="width:4.5em"></label>
<label>Spacing <input type="number" id="f_sp" min="0" max="4" value="1" style="width:4em"></label>
<label>Threshold <input type="range" id="f_thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="f_mono" checked> equal width digits</label></div>
<div id="f_prev" style="display:none"><canvas id="f_canvas" width="840" height="190"></canvas>
<div class="row"><input id="f_sample" value="12:34 do 15 okt 17.5&deg;C" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24">
<button class="b" onclick="fontSave()">Save font</button></div></div>
<p class="hint">Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).</p></div></section>
<section id="t-schedule"><div class="card"><h2>Now</h2>
<div class="row"><span id="sc_now"></span><button class="s" onclick="backToSchedule()">Back to schedule</button></div></div>
<div class="card"><h2>Schedule</h2>
<label><input type="checkbox" id="sc_en" onchange="sched.enabled=this.checked;schedDirty=true"> on</label>
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on.</p></div>
<button class="b" onclick="scSave()">Save schedule and rules</button></section>
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
<div class="row"><b id="swtime" style="font-size:32px;font-variant-numeric:tabular-nums">0:00</b></div>
<div class="row"><button class="b" onclick="stopwatch('start')">Start</button><button class="s" onclick="stopwatch('stop')">Stop</button>
<button class="s" onclick="stopwatch('reset')">Reset</button></div>
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
2026-10-13 01:52:34 +02:00
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
2026-10-13 01:52:34 +02:00
<p class="hint" id="storage"></p></div></section>
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
<canvas id="editor" width="840" height="190"></canvas>
<div class="row"><button class="s" onclick="edClear()">Clear</button><button class="s" onclick="edInvert()">Invert</button>
<label><input type="file" id="file" accept="image/*"> Image</label></div>
<div class="row" id="imgopts" style="display:none">
<label>Threshold <input type="range" id="thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="inv"> Invert</label><label><input type="checkbox" id="dith"> Dither</label>
<label>Fit <select id="fit"><option value="contain">fit</option><option value="cover">fill</option><option value="stretch">stretch</option></select></label></div>
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
<label><input type="checkbox" id="autolive"> live while drawing</label>
<button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button></div>
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).</p></div></section>
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
<label>Display on from</label><input type="time" id="s_day">
<label>Late evening from</label><input type="time" id="s_late">
<label>Quarter hours</label><label><input type="checkbox" id="s_q"> late evening: only at :00 :15 :30 :45</label>
<label>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
</div></div>
<div class="card"><h2>Without WiFi</h2><div class="form">
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" instead of a dynamic page when WiFi is down</label>
</div><p class="hint">The backgrounds per time of day (like the yawning Pikachu) are set per page in the Pages tab.</p></div>
2026-10-13 01:52:34 +02:00
<div class="card"><h2>Hardware</h2><div class="form">
<label>Flip time</label><label><input type="number" id="h_flip" min="50" max="2000" style="width:6em"> &micro;s (50-2000)</label>
<label>Flip gap</label><label><input type="number" id="h_gap" min="0" max="5000" style="width:6em"> &micro;s pause after each pulse (0-5000)</label>
<label>Pulses</label><label><select id="h_pulses"><option>1</option><option>2</option><option>3</option></select> per changed dot</label>
<label>Refresh effect</label><select id="h_effect"><option value="0">Instant (left to right)</option><option value="1">Sparkle</option>
<option value="2">Wipe (left to right)</option><option value="3">Rain (top to bottom)</option><option value="4">From the middle out</option>
<option value="5">Diagonal</option><option value="6">Spiral (outside in)</option><option value="7">Flip all, then reveal</option>
<option value="8">Push (slides in from the right)</option><option value="9">Random</option></select>
<label>Effect for</label><label><input type="checkbox" id="h_small"> small changes only (the clock's minutes); a new page appears at once</label>
<label>Effect time</label><label><input type="number" id="h_efft" min="100" max="10000" style="width:6em"> ms (100-10000, not for Instant; Push needs more when many dots change)</label>
2026-10-13 01:52:34 +02:00
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
</div><p class="hint">Dots that don't flip: a longer gap gives the power supply time to recover between pulses, a second pulse catches dots that missed the first one. Full redraws (after power on, every 2 minutes) use one pulse per dot.</p></div>
<div class="card"><h2>Test patterns</h2>
<div class="row"><button class="s" onclick="pattern('yellow')">All yellow</button><button class="s" onclick="pattern('black')">All black</button>
<button class="s" onclick="pattern('checker')">Checkerboard</button><button class="s" onclick="pattern('checker2')">Checkerboard inverted</button>
<button class="s" onclick="pattern('rows')">Rows</button><button class="s" onclick="pattern('cols')">Columns</button><button class="s" onclick="pattern('border')">Border</button></div>
<div class="row"><button class="s" onclick="cycleTest()">Cycle checkerboards</button><button class="s" onclick="stopTest()">Back to normal</button></div>
<p class="hint">Shown in live mode, with the current flip settings (save them first). Cycle flips every dot back and forth every few seconds: look for dots that stay behind.</p></div>
2026-10-13 01:52:34 +02:00
<button class="b" onclick="saveSettings()">Save settings</button></section>
<section id="t-ha"><div class="card"><h2>Home Assistant (MQTT)</h2><div class="form">
<label>Enabled</label><input type="checkbox" id="ha_en">
<label>Broker</label><input id="ha_host" placeholder="empty: from secrets.h">
<label>Port</label><input type="number" id="ha_port" min="1" max="65535" style="width:7em">
<label>User</label><input id="ha_user" autocomplete="off">
<label>Password</label><input type="password" id="ha_pass" placeholder="unchanged" autocomplete="new-password">
</div><p class="hint">Changing these restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p></div>
<div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form">
<label>URL</label><input id="ha_url" placeholder="empty: http://&lt;broker&gt;:8123">
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute.</p></div>
<button class="b" onclick="saveHa()">Save</button></section>
2026-10-13 01:52:34 +02:00
<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>
<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, the schedule and rules, 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>
2026-10-13 01:52:34 +02:00
</main><div id="msg"></div>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
2026-10-13 01:52:34 +02:00
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}
2026-10-13 01:52:34 +02:00
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
async function api(p,body){const r=await fetch(p,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));if(!r.ok){msg(j.error||('Error '+r.status),true);throw j}return j}
const post=(p,b)=>api(p,b||{});
const same=(a,b)=>!!a&&!!b&&a.toLowerCase()==b.toLowerCase();
function mk(tag,props,...kids){const e=document.createElement(tag);Object.assign(e,props||{});e.append(...kids);return e}
2026-10-13 01:52:34 +02:00
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
// mirror of the display
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
setTimeout(pollScreen,document.hidden?5000:1500)}
async function pollStatus(){try{status=await api('/api/status');
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
$('livebox').style.display=status.live?'flex':'none';swShow(status.stopwatch);
const now='Showing: '+status.shown+' ('+status.shown_status+')';$('sc_now').textContent=now;$('shownnow').textContent=now;
$('backbtn').style.display=/until/.test(status.shown_status)?'':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
2026-10-13 01:52:34 +02:00
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
['HA power switch',status.power_enabled?'on':'off'],
2026-10-13 01:52:34 +02:00
['Storage',status.storage.available?Math.round(status.storage.used/1024)+' of '+Math.round(status.storage.total/1024)+' KB used':'not available']];
$('sysinfo').innerHTML='';r.forEach(([k,v])=>{const a=document.createElement('label');a.textContent=k;const b=document.createElement('span');b.textContent=v;$('sysinfo').append(a,b)})}
// pages, bitmaps and fonts
// each list on its own: one that fails to load doesn't empty the others
async function getList(p,key){try{const j=await api(p);if(Array.isArray(j[key]))return j}catch(e){}msg('Could not load '+p,true);return{[key]:[]}}
// Bitmaps that pages (backgrounds, images) or rules use: shown in their own subsection
function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
for(const p of pageList){(p.page.slots||[]).forEach(s=>add(s.bitmap));
(p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
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}
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]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;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).';
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));
for(const pg of pageList){const tag=pg.builtin?'built-in page':'page';
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('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';
const u=used.has(bm.name.toLowerCase());
$(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
$(u?'offpages_used':'offpages').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'});
2026-10-13 01:52:34 +02:00
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('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
schedBuild();
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()}
2026-10-13 01:52:34 +02:00
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 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()}
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
function swShow(sw){if(sw)$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'')}
async function stopwatch(action){swShow(await post('/api/stopwatch',{action}));if(pe)pePreview()}
2026-10-13 01:52:34 +02:00
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// schedule and rules
const RCMP=[['eq','='],['ne','is not'],['gt','above'],['lt','below']];
const RMODE=[['while','show it while true'],['rotate','add it to the rotation while true'],['for','show it for a while when it becomes true']];
function sinp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;schedDirty=true};return i}
function ssel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));if(!opts.some(x=>x[0]==o[k]))s.append(mk('option',{value:o[k],textContent:o[k]+' (gone)'}));
s.value=o[k];s.onchange=()=>{o[k]=s.value;schedDirty=true;if(after)after()};return s}
function pageOpts(){return pageList.map(p=>[p.name,p.name]).concat(bitmapList.map(b=>[b.name,b.name+' (bitmap)']))}
function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
sched.blocks.forEach((b,i)=>{const t=sinp(b,'start','time');t.onchange=()=>{sched.blocks.sort(bySlot);schedDirty=true;schedBuild()};
const ef=mk('input',{type:'checkbox',checked:b.effect});ef.onchange=()=>{b.effect=ef.checked;schedDirty=true};
const box=mk('div',{className:'el'},mk('div',{className:'row'},mk('b',{textContent:'From'}),t,mk('label',{},ef,' switch with the refresh effect'),
btn('Remove block',()=>{sched.blocks.splice(i,1);schedDirty=true;schedBuild()},'d')));
b.items.forEach((it,j)=>box.append(mk('div',{className:'row'},ssel(it,'page',opts),'for',sinp(it,'seconds','number',{style:'width:5em',min:5,max:3600}),'s',
btn('Remove',()=>{b.items.splice(j,1);schedDirty=true;schedBuild()},'d'))));
if(b.items.length<6)box.append(mk('div',{className:'row'},btn('+ Page',()=>{b.items.push({page:opts.length?opts[0][0]:'',seconds:30});schedDirty=true;schedBuild()})));
d.append(box)});
if(!sched.blocks.length)d.append(mk('p',{className:'hint',textContent:'No blocks: the chosen page is shown.'}));
const rd=$('sc_rules');rd.innerHTML='';
sched.rules.forEach((ru,i)=>{const state=mk('span',{className:'hint'});
const secs=mk('span',{},'for',sinp(ru,'seconds','number',{style:'width:5em',min:1,max:3600}),'s');secs.style.display=ru.mode=='while'?'none':'';
rd.append(mk('div',{className:'el'},
mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}),
sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}),
btn('Test',async()=>{const v=await api('/api/ha/value?entity='+encodeURIComponent(ru.entity)+'&attribute='+encodeURIComponent(ru.attribute));state.textContent='now: '+v.value})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d'))))});
if(!sched.rules.length)rd.append(mk('p',{className:'hint',textContent:'No rules.'}))}
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
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});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',sched);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
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image'};
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow()}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
function peClose(){pe=null;$('pe').style.display='none';$('t-pages').scrollIntoView({behavior:'smooth'})}
function peBuild(){peSlots();peElems()}
function field(label,input){return mk('label',{},label+' ',input)}
function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i}
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name]));
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',opts),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...bitmapList.map(b=>mk('option',{value:b.name}))));
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
if(e.type=='clock')row(field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05'],[2,'08:05:09 (every second)'],[3,'8:05:09 (every second)']])));
if(e.type=='date')row(field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
row(field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
field('Multiply by',inpf(e,'factor',{style:'width:6em'})),field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']])))}
if(e.type=='text')row(field('Text',inp(e,'text','text',{size:24,maxLength:32})));
if(e.type=='countdown'){row(field('Until',inp(e,'target','datetime-local')),field('Format',sel(e,'format',CDF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
row(field('Before',inp(e,'text','text',{size:10,maxLength:32})),field('When passed',inp(e,'suffix','text',{size:8,maxLength:16,placeholder:'0'})))}
if(e.type=='stopwatch'){row(field('Format',sel(e,'format',[[0,'4:05 (every second)'],[1,'0:04 (hours, minutes)'],[2,'0:04:05 (every second)']])),
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
if(TEXTTYPES.includes(e.type)){
row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl);
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
inp(e,'scroll_speed','number',{style:'width:4em',min:1,max:30}),'dots per second')}
else row(...pos,ovl);
box.append(...rows);d.append(box)})}
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
async function peRender(){if(!pe)return;const seq=++peSeq,pos=pe.page.elements.map(e=>[e.x,e.y]),body={page:pePage()};if($('pe_time').value)body.time=$('pe_time').value;
let r,j;try{r=await fetch('/api/pages/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});j=await r.json()}catch(e){j={error:'No connection'}}
if(!pe||seq!=peSeq)return;
if(!r||!r.ok){$('pe_err').textContent=j.error||'Error';$('pe_err').style.color='var(--bad)';return}
$('pe_err').textContent='';peData=fromHex(j.data);pe.boxes=j.boxes;pe.pos=pos;peDraw()}
// the boxes are moved by what changed since the preview was drawn (while dragging)
function peDraw(){if(!pe)return;const c=$('pe_canvas'),g=c.getContext('2d'),s=c.width/W;draw(c,peData||new Uint8Array(W*H));g.lineWidth=2;
pe.boxes.forEach((b,i)=>{const e=pe.page.elements[i],p=pe.pos[i];if(!e||!p)return;const x=b[0]+e.x-p[0],y=b[1]+e.y-p[1];
g.strokeStyle=i==pe.sel?'#4da3ff':'rgba(255,255,255,.35)';g.setLineDash(i==pe.sel?[]:[6,4]);g.strokeRect(x*s-2,y*s-2,Math.max(b[2],1)*s+4,Math.max(b[3],1)*s+4)});g.setLineDash([])}
const pc=$('pe_canvas');
function pcPos(e){const r=pc.getBoundingClientRect();return[(e.clientX-r.left)/r.width*W,(e.clientY-r.top)/r.height*H]}
pc.onpointerdown=e=>{if(!pe)return;const[x,y]=pcPos(e);let hit=-1;
pe.boxes.forEach((b,i)=>{const el=pe.page.elements[i],p=pe.pos[i];if(!el||!p)return;const bx=b[0]+el.x-p[0],by=b[1]+el.y-p[1];
if(x>=bx-1&&x<=bx+b[2]+1&&y>=by-1&&y<=by+b[3]+1)hit=i});
pe.sel=hit;peMark();if(hit<0)return;const el=pe.page.elements[hit];peDrag={i:hit,x0:x,y0:y,ex:el.x,ey:el.y};pc.setPointerCapture(e.pointerId)};
pc.onpointermove=e=>{if(!peDrag)return;const[x,y]=pcPos(e),el=pe.page.elements[peDrag.i],nx=peDrag.ex+Math.round(x-peDrag.x0),ny=peDrag.ey+Math.round(y-peDrag.y0);
if(nx==el.x&&ny==el.y)return;el.x=nx;el.y=ny;peDraw();pePreview()};
pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()};
document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];
if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()});
async function haTest(e){const r=await api('/api/ha/value?entity='+encodeURIComponent(e.entity)+'&attribute='+encodeURIComponent(e.attribute));msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+r.value);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()}
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;
$('f_file').onchange=async e=>{const f=e.target.files[0];if(!f)return;
try{const ff=new FontFace('upload'+Date.now(),await f.arrayBuffer());await ff.load();document.fonts.add(ff);fontFam=ff.family}catch(err){msg('Could not read this font',true);return}
if(!$('f_name').value)$('f_name').value=f.name.replace(/\.[^.]*$/,'').replace(/[^A-Za-z0-9 _-]/g,'').trim().slice(0,24);
$('f_opts').style.display='flex';$('f_prev').style.display='block';fontConvert()};
['f_size','f_base','f_thr','f_sp','f_mono'].forEach(id=>$(id).oninput=fontConvert);$('f_sample').oninput=()=>fontData&&fontPreview();
$('f_h').oninput=()=>{const h=+$('f_h').value;$('f_base').value=Math.round(h*0.8);$('f_size').value=Math.round(h*1.25);fontConvert()};
function fontConvert(){if(!fontFam)return;const h=Math.min(19,Math.max(4,Math.round(+$('f_h').value||10))),size=+$('f_size').value||h,base=+$('f_base').value||h,thr=+$('f_thr').value;
const c=document.createElement('canvas');c.width=Math.ceil(size*2)+16;c.height=h;const g=c.getContext('2d',{willReadFrequently:true});g.font=size+'px "'+fontFam+'"';g.textBaseline='alphabetic';
const glyphs=[];
for(let k=32;k<128;k++){const ch=k==127?'\u00b0':String.fromCharCode(k);g.clearRect(0,0,c.width,h);g.fillStyle='#fff';g.fillText(ch,8,base);
const d=g.getImageData(0,0,c.width,h).data,on=(x,y)=>d[(y*c.width+x)*4+3]>thr;let x0=c.width,x1=-1;
for(let x=0;x<c.width;x++)for(let y=0;y<h;y++)if(on(x,y)){x0=Math.min(x0,x);x1=Math.max(x1,x)}
let w=x1<0?0:Math.min(32,x1-x0+1);const blank=k==32;if(blank)w=Math.min(32,Math.max(1,Math.round(g.measureText(' ').width)));
const rows=[];for(let y=0;y<h;y++){const r=[];for(let x=0;x<w;x++)r.push(!blank&&on(x0+x,y)?1:0);rows.push(r)}glyphs.push({w,rows})}
if($('f_mono').checked){const dg=glyphs.slice(16,26),mw=Math.max(...dg.map(x=>x.w));
dg.forEach(x=>{const l=Math.floor((mw-x.w)/2),r=mw-x.w-l;x.rows=x.rows.map(row=>Array(l).fill(0).concat(row,Array(r).fill(0)));x.w=mw})}
const sp=Math.min(4,Math.max(0,Math.round(+$('f_sp').value||0))),bytes=[70,68,70,49,h,sp,32,96].concat(glyphs.map(x=>x.w));
glyphs.forEach(x=>x.rows.forEach(row=>{for(let i=0;i<x.w;i+=8){let v=0;for(let b=0;b<8;b++)if(row[i+b])v|=0x80>>b;bytes.push(v)}}));
fontData={bytes,glyphs,h,sp};fontPreview()}
function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(const ch of $('f_sample').value){const k=ch=='\u00b0'?95:ch.charCodeAt(0)-32;if(k<0||k>95)continue;const gl=fontData.glyphs[k];if(!gl.w)continue;if(!first)x+=fontData.sp;first=false;
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()}
// 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.schedule=await api('/api/schedule');
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)))})}
if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule';
d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))}
// 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'))}}}
if(groups.has('schedule')){const sc=JSON.parse(JSON.stringify(imp.schedule)),page=n=>to('p',n)||to('b',n)||n;
(sc.blocks||[]).forEach(b=>(b.items||[]).forEach(i=>i.page=page(i.page)));(sc.rules||[]).forEach(r=>r.page=page(r.page));
try{await post('/api/schedule',sc);done++}catch(e){errors.push('Schedule: '+(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)}
2026-10-13 01:52:34 +02:00
// 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;
function edPos(e){const r=ed.c.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*W),Math.floor((e.clientY-r.top)/r.height*H)]}
ed.c.onpointerdown=e=>{const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;painting=ed.bits[y*W+x]?0:1;ed.c.setPointerCapture(e.pointerId);paint(e)};
ed.c.onpointermove=e=>{if(painting!==null)paint(e)};ed.c.onpointerup=()=>{painting=null};
function paint(e){const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;if(ed.bits[y*W+x]!=painting){ed.bits[y*W+x]=painting;draw(ed.c,ed.bits);changed()}}
function changed(){if(!$('autolive').checked)return;clearTimeout(liveTimer);liveTimer=setTimeout(()=>live(ed.bits),300)}
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()}
2026-10-13 01:52:34 +02:00
// image conversion
$('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);
function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d');
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.height;let dw=W,dh=H;
if(fit!='stretch'){const s=fit=='contain'?Math.min(W/iw,H/ih):Math.max(W/iw,H/ih);dw=iw*s;dh=ih*s}
g.drawImage(srcImg,(W-dw)/2,(H-dh)/2,dw,dh);const d=g.getImageData(0,0,W,H).data,l=new Float32Array(W*H);
for(let i=0;i<W*H;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
const thr=+$('thr').value,b=new Uint8Array(W*H),dith=$('dith').checked,inv=$('inv').checked;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
if(dith){const e=l[i]-o;if(x+1<W)l[i+1]+=e*7/16;if(y+1<H){if(x>0)l[i+W-1]+=e*3/16;l[i+W]+=e*5/16;if(x+1<W)l[i+W+1]+=e/16}}}
ed.set(b);changed()}
// test patterns (live mode)
let testTimer=null;
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
p=='yellow'?1:p=='black'?0:p=='checker'?(x+y)%2:p=='checker2'?(x+y+1)%2:p=='rows'?y%2:p=='cols'?x%2:(x==0||y==0||x==W-1||y==H-1)?1:0;return b}
async function pattern(p){clearInterval(testTimer);testTimer=null;await live(patternBits(p))}
async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(patternBits(n++%2?'checker2':'checker')).catch(()=>{clearInterval(testTimer);testTimer=null});
await step();testTimer=setInterval(step,Math.max(4000,(settings.drive.effect?settings.drive.effect_time:0)+2000))}
async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()}
2026-10-13 01:52:34 +02:00
// settings
function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;$('s_late').value=s.schedule.late_start;
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;
2026-10-13 01:52:34 +02:00
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;
2026-10-13 01:52:34 +02:00
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'}
2026-10-13 01:52:34 +02:00
function collect(){const s=JSON.parse(JSON.stringify(settings));
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
s.clock={no_wifi_screen:$('c_nw').checked};
2026-10-13 01:52:34 +02:00
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s}
2026-10-13 01:52:34 +02:00
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
async function saveHa(){await saveSettings()}
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadLists()}
2026-10-13 01:52:34 +02:00
start();
</script></body></html>)rawliteral";