2026-10-13 01:52:34 +02:00
#pragma once
#include <pgmspace.h>
// The web UI: one page, the browser does the image work (conversion, editor, preview).
// API: see web/web_ui.cpp. 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 : # 16181 d ; -- card : # 22252 c ; -- line : # 363 a44 ; -- text : # e8e8e8 ; -- dim : # 9 aa0aa ; -- dot : # ffd21f ; -- off : # 2 c2f36 ; -- accent : # ffd21f ; -- bad : # ff6b6b }
* { box - sizing : border - box } body { margin : 0 ; font - family : system - ui , sans - serif ; background : var ( -- bg ); color : var ( -- text )}
header { padding : 12 px 16 px ; border - bottom : 1 px solid var ( -- line ); display : flex ; gap : 12 px ; align - items : baseline ; flex - wrap : wrap }
header h1 { font - size : 18 px ; margin : 0 } # statusline { color : var ( -- dim ); font - size : 13 px }
main { max - width : 900 px ; margin : 0 auto ; padding : 12 px 16 px }
. mirror { background : # 000 ; border - radius : 10 px ; padding : 10 px }. mirror canvas { width : 100 % ; display : block }
#mirrorinfo{color:var(--dim);font-size:13px;margin:6px 2px 12px}
nav { display : flex ; gap : 4 px ; flex - wrap : wrap ; margin - bottom : 12 px }
nav button { background : var ( -- card ); border : 1 px solid var ( -- line ); color : var ( -- text ); padding : 8 px 12 px ; border - radius : 8 px ; cursor : pointer }
nav button . on { border - color : var ( -- accent ); color : var ( -- accent )}
section { display : none } section . on { display : block }
. card { background : var ( -- card ); border : 1 px solid var ( -- line ); border - radius : 10 px ; padding : 12 px ; margin - bottom : 12 px }
h2 { font - size : 15 px ; margin : 0 0 10 px }
. grid { display : grid ; grid - template - columns : repeat ( auto - fill , minmax ( 190 px , 1f r )); gap : 10 px }
. item { background : var ( -- bg ); border : 1 px solid var ( -- line ); border - radius : 8 px ; padding : 8 px ; cursor : pointer }
. item . cur { border - color : var ( -- accent )}. item canvas { width : 100 % ; background : # 000 ; border - radius : 4 px }
. item . name { font - size : 13 px ; margin : 6 px 0 4 px ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap }
. item . tag { color : var ( -- dim ); font - size : 11 px }
. row { display : flex ; gap : 8 px ; flex - wrap : wrap ; align - items : center ; margin : 6 px 0 }
label { font - size : 14 px } input , select { background : var ( -- bg ); color : var ( -- text ); border : 1 px solid var ( -- line ); border - radius : 6 px ; padding : 6 px }
input [ type = checkbox ]{ transform : scale ( 1.2 )} input [ type = range ]{ padding : 0 }
button . b { background : var ( -- accent ); color : # 000 ; border : 0 ; border - radius : 6 px ; padding : 7 px 12 px ; cursor : pointer ; font - weight : 600 }
button . s { background : var ( -- bg ); color : var ( -- text ); border : 1 px solid var ( -- line ); border - radius : 6 px ; padding : 6 px 10 px ; cursor : pointer }
button . d { color : var ( -- bad )}
#editor{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
. form { display : grid ; grid - template - columns : max - content 1f r ; gap : 8 px 12 px ; align - items : center }
. hint { color : var ( -- dim ); font - size : 12 px } # msg { position : fixed ; bottom : 16 px ; left : 50 % ; transform : translateX ( - 50 % ); background : var ( -- card ); border : 1 px solid var ( -- line ); padding : 8 px 14 px ; border - radius : 8 px ; 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 >
2026-10-15 01:29:01 +02:00
< 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 >
2026-10-13 01:52:34 +02:00
< 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 > 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 > Hardware </ h2 >< div class = "form" >
< label > Flip time </ label >< label >< input type = "number" id = "h_flip" min = "50" max = "2000" style = "width:6em" > & micro ; s ( 50 - 2000 ) </ label >
2026-10-15 01:29:01 +02:00
< label > Flip gap </ label >< label >< input type = "number" id = "h_gap" min = "0" max = "5000" style = "width:6em" > & micro ; s pause after each pulse ( 0 - 5000 ) </ label >
< label > Pulses </ label >< label >< select id = "h_pulses" >< option > 1 </ option >< option > 2 </ option >< option > 3 </ option ></ select > per changed dot </ label >
< label > Refresh effect </ label >< select id = "h_effect" >< option value = "0" > Instant ( left to right ) </ option >< option value = "1" > Sparkle </ option >
< option value = "2" > Wipe ( left to right ) </ option >< option value = "3" > Rain ( top to bottom ) </ option >< option value = "4" > From the middle out </ option >
< option value = "5" > Diagonal </ option >< option value = "6" > Spiral ( outside in ) </ option >< option value = "7" > Flip all , then reveal </ option >
< option value = "8" > Push ( slides in from the right ) </ option >< option value = "9" > Random </ option ></ select >
< label > Effect for </ label >< label >< input type = "checkbox" id = "h_small" > small changes only ( the clock ' s minutes ); a new page appears at once </ label >
< label > Effect time </ label >< label >< input type = "number" id = "h_efft" min = "100" max = "10000" style = "width:6em" > ms ( 100 - 10000 , not for Instant ; Push needs more when many dots change ) </ label >
2026-10-13 01:52:34 +02:00
< label > Status LED </ label >< label >< input type = "checkbox" id = "h_led" > blink while the display is on </ label >
2026-10-15 01:29:01 +02:00
</ div >< p class = "hint" > Dots that don ' t flip : a longer gap gives the power supply time to recover between pulses , a second pulse catches dots that missed the first one . Full redraws ( after power on , every 2 minutes ) use one pulse per dot . </ p ></ div >
< div class = "card" >< h2 > Test patterns </ h2 >
< div class = "row" >< button class = "s" onclick = "pattern('yellow')" > All yellow </ button >< button class = "s" onclick = "pattern('black')" > All black </ button >
< button class = "s" onclick = "pattern('checker')" > Checkerboard </ button >< button class = "s" onclick = "pattern('checker2')" > Checkerboard inverted </ button >
< button class = "s" onclick = "pattern('rows')" > Rows </ button >< button class = "s" onclick = "pattern('cols')" > Columns </ button >< button class = "s" onclick = "pattern('border')" > Border </ button ></ div >
< div class = "row" >< button class = "s" onclick = "cycleTest()" > Cycle checkerboards </ button >< button class = "s" onclick = "stopTest()" > Back to normal </ button ></ div >
< p class = "hint" > Shown in live mode , with the current flip settings ( save them first ). Cycle flips every dot back and forth every few seconds : look for dots that stay behind . </ p ></ div >
2026-10-13 01:52:34 +02:00
< button class = "b" onclick = "saveSettings()" > Save settings </ button ></ section >
< section id = "t-ha" >< div class = "card" >< h2 > Home Assistant ( MQTT ) </ h2 >< div class = "form" >
< label > Enabled </ label >< input type = "checkbox" id = "ha_en" >
< label > Broker </ label >< input id = "ha_host" placeholder = "empty: from secrets.h" >
< label > Port </ label >< input type = "number" id = "ha_port" min = "1" max = "65535" style = "width:7em" >
< label > User </ label >< input id = "ha_user" autocomplete = "off" >
< label > Password </ label >< input type = "password" id = "ha_pass" placeholder = "unchanged" autocomplete = "new-password" >
2026-10-15 01:29:01 +02:00
</ div >< p class = "hint" > Saving restarts the ESP32 . Entities : Power switch , Mode and Off image dropdowns , IP and Firmware sensors , Recovery button . </ p >
2026-10-13 01:52:34 +02:00
< button class = "b" onclick = "saveHa()" > Save and restart </ button ></ div ></ 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 ></ section >
</ 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 ' ]];
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 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 || {});
// 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 ( ' 2 d ' ), 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 ':' # 2 c2f36 ' ; 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 ){}
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 )}
2026-10-15 01:29:01 +02:00
function sysinfo (){ if ( ! status ) return ; const r = [[ ' Firmware ' , status . firmware ],[ ' Last restart ' , status . reset_reason ],[ ' Uptime ' , Math . floor ( status . uptime / 3600 ) + ' h ' + Math . floor ( status . uptime % 3600 / 60 ) + ' min ' ],
2026-10-13 01:52:34 +02:00
[ ' WiFi ' , status . wifi . ssid + ' ( ' + status . wifi . ip + ' , ' + status . wifi . rssi + ' dBm ) ' ],[ ' Home Assistant ' , status . ha . enabled ? ( status . ha . connected ? ' connected ':' not connected ' ) : ' disabled ' ],
2026-10-15 01:29:01 +02:00
[ ' HA power switch ' , status . power_enabled ? ' on ':' off ' ],
2026-10-13 01:52:34 +02:00
[ ' Storage ' , status . storage . available ? Math . round ( status . storage . used / 1024 ) + ' of ' + Math . round ( status . storage . total / 1024 ) + ' KB used ':' not available ' ]];
$ ( ' sysinfo ' ). innerHTML = '' ; r . forEach (([ k , v ]) => { const a = document . createElement ( ' label ' ); a . textContent = k ; const b = document . createElement ( ' span ' ); b . textContent = v ; $ ( ' sysinfo ' ). append ( a , b )})}
// pages 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 ). ' ;
2026-10-15 01:29:01 +02:00
const pages = $ ( ' pages ' ), offs = $ ( ' offpages ' ), bms = $ ( ' bitmaps ' ); pages . innerHTML = '' ; offs . innerHTML = '' ; bms . innerHTML = '' ;
offs . appendChild ( card ( ' Blank ',' all dots off ' , new Uint8Array ( W * H ),() => selectOff ( '' ), settings &&! settings . off_page ));
2026-10-13 01:52:34 +02:00
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 ));
2026-10-15 01:29:01 +02:00
offs . appendChild ( card ( bm . name , bm . builtin ? ' built - in ':' ' , data ,() => selectOff ( bm . name ), settings && settings . off_page == bm . name ));
2026-10-13 01:52:34 +02:00
const c = card ( bm . name , bm . builtin ? ' built - in ':' ' , data , null ); const r = document . createElement ( ' div ' ); r . 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 )}}
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 : 76 px ; display : flex ; align - items : center ; justify - content : center ; font - size : 28 px ; background : # 000 ; border - radius : 4 px ; 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 ()}
2026-10-15 01:29:01 +02:00
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 ()}
2026-10-13 01:52:34 +02:00
async function live ( bits ){ await post ( ' / api / live ' ,{ data : toHex ( bits )}); msg ( ' Shown on the display ' )}
async function stopLive (){ await post ( ' / api / live / stop ' ); msg ( ' Back to normal ' )}
// 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 ); loadBitmaps ()}
// 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 ( ' 2 d ' );
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 ()}
2026-10-15 01:29:01 +02:00
// test patterns (live mode)
let testTimer = null ;
function patternBits ( p ){ const b = new Uint8Array ( W * H ); for ( let y = 0 ; y < H ; y ++ ) for ( let x = 0 ; x < W ; x ++ ) b [ y * W + x ] =
p == ' yellow ' ? 1 : p == ' black ' ? 0 : p == ' checker ' ? ( x + y ) % 2 : p == ' checker2 ' ? ( x + y + 1 ) % 2 : p == ' rows ' ? y % 2 : p == ' cols ' ? x % 2 : ( x == 0 || y == 0 || x == W - 1 || y == H - 1 ) ? 1 : 0 ; return b }
async function pattern ( p ){ clearInterval ( testTimer ); testTimer = null ; await live ( patternBits ( p ))}
async function cycleTest (){ clearInterval ( testTimer ); let n = 0 ; const step = () => live ( patternBits ( n ++% 2 ? ' checker2 ':' checker ' )). catch (() => { clearInterval ( testTimer ); testTimer = null });
await step (); testTimer = setInterval ( step , Math . max ( 4000 ,( settings . drive . effect ? settings . drive . effect_time : 0 ) + 2000 ))}
async function stopTest (){ clearInterval ( testTimer ); testTimer = null ; await stopLive ()}
2026-10-13 01:52:34 +02:00
// settings
function fill (){ const s = settings ; $ ( ' s_en ' ). checked = s . schedule . enabled ; $ ( ' s_day ' ). value = s . schedule . day_start ; $ ( ' s_late ' ). value = s . schedule . late_start ;
$ ( ' s_q ' ). checked = s . schedule . quarter_hours ; $ ( ' s_night ' ). value = s . schedule . night_start ; $ ( ' c_ev ' ). checked = s . clock . evening_enabled ; $ ( ' c_evt ' ). value = s . clock . evening_start ;
$ ( ' c_nw ' ). checked = s . clock . no_wifi_screen ; $ ( ' h_flip ' ). value = s . flip_time ; $ ( ' h_led ' ). checked = s . status_led ;
2026-10-15 01:29:01 +02:00
$ ( ' h_gap ' ). value = s . drive . flip_gap ; $ ( ' h_pulses ' ). value = s . drive . pulses ; $ ( ' h_effect ' ). value = s . drive . effect ; $ ( ' h_efft ' ). value = s . drive . effect_time ; $ ( ' h_small ' ). checked = s . drive . effect_small_only ;
2026-10-13 01:52:34 +02:00
$ ( ' ha_en ' ). checked = s . home_assistant . enabled ; $ ( ' ha_host ' ). value = s . home_assistant . host ; $ ( ' ha_port ' ). value = s . home_assistant . port ; $ ( ' ha_user ' ). value = s . home_assistant . user ;
$ ( ' ha_pass ' ). placeholder = s . home_assistant . password_set ? ' unchanged ':' none ' }
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 . flip_time =+ $ ( ' h_flip ' ). value ; s . status_led = $ ( ' h_led ' ). checked ;
2026-10-15 01:29:01 +02:00
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 };
2026-10-13 01:52:34 +02:00
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 }
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 ()}
start ();
</ script ></ body ></ html > ) rawliteral ";