Public Access
Add dynamic pages with clock, date, Home Assistant values and fonts
The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page has a background bitmap per time of day and up to 8 elements (clock, date, Home Assistant value, text), each with position, alignment, font and overlay. Pages are drawn on the ESP32, also for the web previews. - Pages tab in the web UI: page editor with drag and drop, font upload (TTF/OTF converted in the browser to the FDF1 dot font format) - Built-in fonts Small, Normal, Large and Full height (generated by tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning - Home Assistant values over the REST API (URL and token in the HA tab) - Settings version 4: evening settings removed (now time slots of the Clock page), old records are converted - HA Mode dropdown lists the dynamic pages and bitmaps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d3b084de1
commit
0411804a2f
49 files changed
+3058
-331
No files matched your search
+180
-40
@@ -2,14 +2,16 @@
|
||||
|
||||
#include <pgmspace.h>
|
||||
|
||||
// The web UI: one page, the browser does the image work (conversion, editor, preview).
|
||||
// API: see web/web_ui.cpp. Bitmaps are 84 x 19, as 418 hex digits in drawBitmap format.
|
||||
// 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}
|
||||
: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}
|
||||
@@ -21,19 +23,21 @@ nav button{background:var(--card);border:1px solid var(--line);color:var(--text)
|
||||
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}
|
||||
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}
|
||||
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{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
|
||||
#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>
|
||||
@@ -50,6 +54,37 @@ button.d{color:var(--bad)}
|
||||
<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></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.</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" 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>
|
||||
|
||||
@@ -74,10 +109,9 @@ button.d{color:var(--bad)}
|
||||
<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>Clock</h2><div class="form">
|
||||
<label>Yawning Pikachu</label><label><input type="checkbox" id="c_ev"> from <input type="time" id="c_evt"></label>
|
||||
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" when WiFi is down</label>
|
||||
</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>
|
||||
@@ -104,8 +138,12 @@ button.d{color:var(--bad)}
|
||||
<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">Saving restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p>
|
||||
<button class="b" onclick="saveHa()">Save and restart</button></div></section>
|
||||
</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>
|
||||
@@ -114,21 +152,22 @@ button.d{color:var(--bad)}
|
||||
</main><div id="msg"></div>
|
||||
<script>
|
||||
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
|
||||
let settings=null,status=null,bitmapList=[];
|
||||
const tabs=[['show','Show'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
|
||||
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){tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
|
||||
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}
|
||||
const hm=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
|
||||
// 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){}
|
||||
@@ -141,28 +180,128 @@ function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Las
|
||||
['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 and bitmaps
|
||||
async function loadBitmaps(){const l=await api('/api/bitmaps');bitmapList=l.bitmaps;
|
||||
$('storage').textContent=l.storage?'':'Bitmap storage not available: install the new flash layout (see README).';
|
||||
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';
|
||||
// pages, bitmaps and fonts
|
||||
async function loadLists(){const[b,p,f]=await Promise.all([api('/api/bitmaps'),api('/api/pages'),api('/api/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));
|
||||
pages.appendChild(card('Clock','Pikachu with the time',null,()=>selectPage('clock'),settings&&settings.page=='clock'));
|
||||
for(const bm of bitmapList){const data=fromHex(bm.data);bm.bits=data;
|
||||
pages.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectPage(bm.name),settings&&settings.page==bm.name));
|
||||
offs.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectOff(bm.name),settings&&settings.off_page==bm.name));
|
||||
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=document.createElement('div');r.className='row';
|
||||
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('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
|
||||
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
|
||||
pages.appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
|
||||
offs.appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
|
||||
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
|
||||
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
|
||||
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}}
|
||||
if(!bm.builtin)r.append(btn('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=document.createElement('div');d.className='item'+(cur?' cur':'');
|
||||
if(bits)d.appendChild(mini(bits));else{const c=document.createElement('div');c.style.cssText='height:76px;display:flex;align-items:center;justify-content:center;font-size:28px;background:#000;border-radius:4px;color:#ffd21f';c.textContent='12:34';d.appendChild(c)}
|
||||
const n=document.createElement('div');n.className='name';n.textContent=name;const t=document.createElement('div');t.className='tag';t.textContent=tag;d.append(n,t);
|
||||
if(onclick)d.onclick=onclick;return d}
|
||||
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadBitmaps()}
|
||||
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadBitmaps()}
|
||||
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadBitmaps()}
|
||||
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()}
|
||||
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'};
|
||||
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 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:''}}
|
||||
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'}
|
||||
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 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='';
|
||||
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'))];
|
||||
if(e.type=='clock')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05']]))));
|
||||
if(e.type=='date')rows.push(mk('div',{className:'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'){rows.push(mk('div',{className:'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))));
|
||||
rows.push(mk('div',{className:'row'},field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
|
||||
field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']]))))}
|
||||
if(e.type=='text')rows.push(mk('div',{className:'row'},field('Text',inp(e,'text','text',{size:24,maxLength:32}))));
|
||||
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)']);
|
||||
rows.push(mk('div',{className:'row'},field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'})),
|
||||
field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),
|
||||
field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]))));
|
||||
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<127;k++){const ch=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,95].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.charCodeAt(0)-32;if(k<0||k>94)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()}
|
||||
// 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;
|
||||
@@ -175,7 +314,7 @@ 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);loadBitmaps()}
|
||||
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);
|
||||
@@ -198,20 +337,21 @@ async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(
|
||||
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_ev').checked=s.clock.evening_enabled;$('c_evt').value=s.clock.evening_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_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()});
|
||||
Object.assign(s.clock,{evening_enabled:$('c_ev').checked,evening_start:$('c_evt').value,no_wifi_screen:$('c_nw').checked});
|
||||
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()});
|
||||
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;return s}
|
||||
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();loadBitmaps()}
|
||||
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadLists()}
|
||||
start();
|
||||
</script></body></html>)rawliteral";
|
||||
Reference in new issue
Block a user