Add scrolling, countdown, stopwatch, images, seconds, multiplier and degree sign

- 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>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:01:14 +02:00
1 parent ca8d78ea8a
commit fc6fa1a180
38 files changed
+1370 -277

No files matched your search

+48 -21
View File
@@ -50,6 +50,8 @@ button.d{color:var(--bad)}
<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>
@@ -68,8 +70,10 @@ button.d{color:var(--bad)}
<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>
<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>
@@ -81,7 +85,7 @@ button.d{color:var(--bad)}
<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">
<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>
@@ -183,7 +187,7 @@ async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'
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';sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
$('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'],
@@ -219,13 +223,20 @@ async function selectOff(p){const r=await post('/api/settings',{off_page:p});set
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'};
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 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 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','')}
@@ -233,6 +244,7 @@ function peClose(){pe=null;$('pe').style.display='none';$('t-pages').scrollIntoV
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]));
@@ -242,21 +254,36 @@ function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','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='';
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'))];
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 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)']);
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']]))));
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()}
@@ -297,18 +324,18 @@ $('f_h').oninput=()=>{const h=+$('f_h').value;$('f_base').value=Math.round(h*0.8
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);
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,95].concat(glyphs.map(x=>x.w));
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.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(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}