Public Access
- Text elements can scroll in a window at a set speed; clock, countdown and stopwatch can show seconds. Moving pages are sent as instant frames (new Nano command D1) and paced by the estimated flip time. - Countdown to a date and time (smart, days, days + time, with seconds), stopwatch (web UI, HA switch and reset button), image element (fixed or by Home Assistant state), multiplier for HA values, degree sign (char 127) in all fonts and the font converter. - Page format version 2; version 1 pages are converted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
466 lines
49 KiB
C++
466 lines
49 KiB
C++
#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.
|
|
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}
|
|
*{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)}
|
|
.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%}
|
|
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}
|
|
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
|
|
.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="grid" id="pages"></div>
|
|
<div class="row"><b>Stopwatch</b> <span id="swtime">0:00</span><button class="s" 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">The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.</p></div>
|
|
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
|
|
<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>
|
|
|
|
<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 ° 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°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-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
|
|
<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>
|
|
<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"> µs (50-2000)</label>
|
|
<label>Flip gap</label><label><input type="number" id="h_gap" min="0" max="5000" style="width:6em"> µ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>
|
|
<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>
|
|
<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://<broker>: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>
|
|
|
|
<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 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);
|
|
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[];
|
|
const tabs=[['show','Show'],['pages','Pages'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
|
|
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
|
|
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
|
|
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}
|
|
// 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);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'],
|
|
['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'],
|
|
['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]:[]}}
|
|
async function loadLists(){const[b,p,f]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts')]);
|
|
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;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');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';dyn.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';
|
|
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('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()}
|
|
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()}
|
|
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')}
|
|
// 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.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;
|
|
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()}
|
|
// 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()}
|
|
// 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;
|
|
$('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;
|
|
$('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'}
|
|
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};
|
|
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}
|
|
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()}
|
|
start();
|
|
</script></body></html>)rawliteral";
|