Public Access
Add a web UI to the clock firmware
- Web UI on port 80: live view of the display, choose the page (clock or a bitmap), bitmaps (list, show, live, edit, delete), draw / image upload with conversion in the browser, live mode, settings, Home Assistant - Live view from a shadow buffer in the BROSE9323 driver (shownPixel) - Settings in NVS with the original behaviour as defaults: schedule times, quarter hours, yawning Pikachu, No WiFi screen, flip time, status LED, Home Assistant broker (restart on change) - Bitmaps on LittleFS; new 320 KB partition, ota slots now 1.31 MB - DisplayController: pages, live mode, settings; app.cpp holds the objects - README for the recovery app; docs for the web UI - Tests: settings, time parsing, bitmap format and names, schedule with settings, pages and live mode (95 total) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9e0800e36
commit
2b1ac12ed7
34 files changed
+1989
-117
No files matched your search
@@ -0,0 +1,188 @@
|
||||
#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:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--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: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}
|
||||
.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}
|
||||
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}
|
||||
.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>
|
||||
<p class="hint">The schedule (Settings) decides when the display is on. The Home Assistant "Clock" switch shows the Nintendo logo when off.</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>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"> µs (50-2000)</label>
|
||||
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
|
||||
</div></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">Saving restarts the ESP32. Entities: Clock and Power switches, Firmware sensor, Recovery button.</p>
|
||||
<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('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){}
|
||||
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)}
|
||||
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['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 switches','Clock '+(status.clock_enabled?'on':'off')+', Power '+(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'),bms=$('bitmaps');pages.innerHTML='';bms.innerHTML='';
|
||||
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));
|
||||
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: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 delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');loadBitmaps()}
|
||||
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('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()}
|
||||
// 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;
|
||||
$('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;
|
||||
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";
|
||||
@@ -0,0 +1,403 @@
|
||||
#include "web/web_ui.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <WebServer.h>
|
||||
#include <WiFi.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "app.h"
|
||||
#include "config.h"
|
||||
#include "display/bitmap_store.h"
|
||||
#include "logic/bitmap_format.h"
|
||||
#include "net/home_assistant.h"
|
||||
#include "system/boot_target.h"
|
||||
#include "system/firmware_guard.h"
|
||||
#include "web/web_page.h"
|
||||
|
||||
static WebServer server(80);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void sendJson(int code, const JsonDocument &doc)
|
||||
{
|
||||
String out;
|
||||
serializeJson(doc, out);
|
||||
server.send(code, "application/json", out);
|
||||
}
|
||||
|
||||
static void sendError(int code, const char *message)
|
||||
{
|
||||
JsonDocument doc;
|
||||
doc["error"] = message;
|
||||
sendJson(code, doc);
|
||||
}
|
||||
|
||||
static void sendOk()
|
||||
{
|
||||
server.send(200, "application/json", "{\"ok\":true}");
|
||||
}
|
||||
|
||||
static bool readBody(JsonDocument &doc)
|
||||
{
|
||||
if (deserializeJson(doc, server.arg("plain")))
|
||||
{
|
||||
sendError(400, "Invalid JSON");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void copyText(JsonVariantConst value, char *out, size_t size)
|
||||
{
|
||||
if (!value.is<const char *>())
|
||||
return;
|
||||
strncpy(out, value.as<const char *>(), size - 1);
|
||||
out[size - 1] = '\0';
|
||||
}
|
||||
|
||||
static void copyBool(JsonVariantConst value, bool &out)
|
||||
{
|
||||
if (value.is<bool>())
|
||||
out = value.as<bool>();
|
||||
}
|
||||
|
||||
static bool copyTime(JsonVariantConst value, uint16_t &out)
|
||||
{
|
||||
if (value.isNull())
|
||||
return true;
|
||||
return parseTime(value.as<const char *>(), out);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page and status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void handleRoot()
|
||||
{
|
||||
server.send_P(200, "text/html", WEB_PAGE);
|
||||
}
|
||||
|
||||
static void handleStatus()
|
||||
{
|
||||
JsonDocument doc;
|
||||
doc["version"] = FIRMWARE_VERSION;
|
||||
doc["firmware"] = firmwareGuardStatus();
|
||||
doc["uptime"] = millis() / 1000;
|
||||
|
||||
struct tm now = {};
|
||||
if (getLocalTime(&now, 0))
|
||||
{
|
||||
char time[6];
|
||||
formatTime(now.tm_hour * 60 + now.tm_min, time);
|
||||
doc["time"] = time;
|
||||
}
|
||||
|
||||
JsonObject wifi = doc["wifi"].to<JsonObject>();
|
||||
wifi["ssid"] = WiFi.SSID();
|
||||
wifi["ip"] = WiFi.localIP().toString();
|
||||
wifi["rssi"] = WiFi.RSSI();
|
||||
|
||||
doc["display_on"] = nano.isOn();
|
||||
doc["live"] = controller.liveActive();
|
||||
doc["page"] = appSettings().page;
|
||||
doc["clock_enabled"] = controller.clockEnabled();
|
||||
doc["power_enabled"] = controller.powerEnabled();
|
||||
|
||||
JsonObject ha = doc["ha"].to<JsonObject>();
|
||||
ha["enabled"] = appSettings().home_assistant.enabled;
|
||||
ha["connected"] = homeAssistantConnected();
|
||||
|
||||
size_t used, total;
|
||||
bitmapStorageUsage(used, total);
|
||||
JsonObject storage = doc["storage"].to<JsonObject>();
|
||||
storage["available"] = bitmapStorageAvailable();
|
||||
storage["used"] = used;
|
||||
storage["total"] = total;
|
||||
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
// What the dots show now, from the driver's shadow copy
|
||||
static void handleScreen()
|
||||
{
|
||||
uint8_t bitmap[BITMAP_BYTES] = {};
|
||||
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||
setBitmapPixel(bitmap, x, y, display.shownPixel(x, y));
|
||||
char hex[BITMAP_HEX_LENGTH + 1];
|
||||
bitmapToHex(bitmap, hex);
|
||||
|
||||
JsonDocument doc;
|
||||
doc["on"] = nano.isOn();
|
||||
doc["live"] = controller.liveActive();
|
||||
doc["data"] = hex;
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitmaps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Streamed: with many bitmaps the JSON is too big to build in memory at once
|
||||
static void handleBitmaps()
|
||||
{
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "application/json", "");
|
||||
server.sendContent(bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":[");
|
||||
|
||||
bool first = true;
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
char hex[BITMAP_HEX_LENGTH + 1];
|
||||
for (const BitmapInfo &info : listBitmaps())
|
||||
{
|
||||
if (!loadBitmap(info.name.c_str(), bitmap))
|
||||
continue;
|
||||
bitmapToHex(bitmap, hex);
|
||||
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
|
||||
String item = String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" +
|
||||
(info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}";
|
||||
server.sendContent(item);
|
||||
first = false;
|
||||
}
|
||||
server.sendContent("]}");
|
||||
server.sendContent("");
|
||||
}
|
||||
|
||||
static void handleBitmapGet()
|
||||
{
|
||||
String name = server.arg("name");
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
if (!loadBitmap(name.c_str(), bitmap))
|
||||
{
|
||||
sendError(404, "Bitmap not found");
|
||||
return;
|
||||
}
|
||||
char hex[BITMAP_HEX_LENGTH + 1];
|
||||
bitmapToHex(bitmap, hex);
|
||||
JsonDocument doc;
|
||||
doc["name"] = name;
|
||||
doc["data"] = hex;
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
static void handleBitmapSave()
|
||||
{
|
||||
JsonDocument body;
|
||||
if (!readBody(body))
|
||||
return;
|
||||
const char *name = body["name"] | "";
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
if (!bitmapFromHex(body["data"] | "", bitmap))
|
||||
{
|
||||
sendError(400, "Invalid bitmap data");
|
||||
return;
|
||||
}
|
||||
const char *error = saveBitmap(name, bitmap);
|
||||
if (error)
|
||||
{
|
||||
sendError(400, error);
|
||||
return;
|
||||
}
|
||||
appBitmapChanged(name);
|
||||
sendOk();
|
||||
}
|
||||
|
||||
static void handleBitmapDelete()
|
||||
{
|
||||
JsonDocument body;
|
||||
if (!readBody(body))
|
||||
return;
|
||||
const char *name = body["name"] | "";
|
||||
const char *error = deleteBitmap(name);
|
||||
if (error)
|
||||
{
|
||||
sendError(400, error);
|
||||
return;
|
||||
}
|
||||
if (strcmp(appSettings().page, name) == 0)
|
||||
appSelectPage(PAGE_CLOCK);
|
||||
sendOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page and live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void handlePage()
|
||||
{
|
||||
JsonDocument body;
|
||||
if (!readBody(body))
|
||||
return;
|
||||
const char *error = appSelectPage(body["page"] | "");
|
||||
if (error)
|
||||
sendError(400, error);
|
||||
else
|
||||
sendOk();
|
||||
}
|
||||
|
||||
static void handleLive()
|
||||
{
|
||||
JsonDocument body;
|
||||
if (!readBody(body))
|
||||
return;
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
if (!bitmapFromHex(body["data"] | "", bitmap))
|
||||
{
|
||||
sendError(400, "Invalid bitmap data");
|
||||
return;
|
||||
}
|
||||
if (!controller.powerEnabled())
|
||||
{
|
||||
sendError(409, "The display is switched off in Home Assistant (Power switch)");
|
||||
return;
|
||||
}
|
||||
appShowLive(bitmap);
|
||||
sendOk();
|
||||
}
|
||||
|
||||
static void handleLiveStop()
|
||||
{
|
||||
appStopLive();
|
||||
sendOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void settingsToJson(const Settings &s, JsonObject o)
|
||||
{
|
||||
char time[6];
|
||||
o["page"] = s.page;
|
||||
o["flip_time"] = s.flip_time;
|
||||
o["status_led"] = s.status_led;
|
||||
|
||||
JsonObject schedule = o["schedule"].to<JsonObject>();
|
||||
schedule["enabled"] = s.schedule.enabled;
|
||||
formatTime(s.schedule.day_start, time);
|
||||
schedule["day_start"] = String(time);
|
||||
formatTime(s.schedule.late_start, time);
|
||||
schedule["late_start"] = String(time);
|
||||
schedule["quarter_hours"] = s.schedule.quarter_hours;
|
||||
formatTime(s.schedule.night_start, time);
|
||||
schedule["night_start"] = String(time);
|
||||
|
||||
JsonObject clock = o["clock"].to<JsonObject>();
|
||||
clock["evening_enabled"] = s.clock.evening_enabled;
|
||||
formatTime(s.clock.evening_start, time);
|
||||
clock["evening_start"] = String(time);
|
||||
clock["no_wifi_screen"] = s.clock.no_wifi_screen;
|
||||
|
||||
JsonObject ha = o["home_assistant"].to<JsonObject>();
|
||||
ha["enabled"] = s.home_assistant.enabled;
|
||||
ha["host"] = s.home_assistant.host;
|
||||
ha["port"] = s.home_assistant.port;
|
||||
ha["user"] = s.home_assistant.user;
|
||||
ha["password_set"] = s.home_assistant.password[0] != '\0';
|
||||
}
|
||||
|
||||
static void handleSettingsGet()
|
||||
{
|
||||
JsonDocument doc;
|
||||
settingsToJson(appSettings(), doc.to<JsonObject>());
|
||||
sendJson(200, doc);
|
||||
}
|
||||
|
||||
// Only the fields in the request change
|
||||
static void handleSettingsPost()
|
||||
{
|
||||
JsonDocument body;
|
||||
if (!readBody(body))
|
||||
return;
|
||||
Settings s = appSettings();
|
||||
|
||||
copyText(body["page"], s.page, sizeof(s.page));
|
||||
if (body["flip_time"].is<int>())
|
||||
s.flip_time = body["flip_time"].as<int>();
|
||||
copyBool(body["status_led"], s.status_led);
|
||||
|
||||
JsonVariantConst schedule = body["schedule"];
|
||||
copyBool(schedule["enabled"], s.schedule.enabled);
|
||||
copyBool(schedule["quarter_hours"], s.schedule.quarter_hours);
|
||||
JsonVariantConst clock = body["clock"];
|
||||
copyBool(clock["evening_enabled"], s.clock.evening_enabled);
|
||||
copyBool(clock["no_wifi_screen"], s.clock.no_wifi_screen);
|
||||
if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["late_start"], s.schedule.late_start) ||
|
||||
!copyTime(schedule["night_start"], s.schedule.night_start) || !copyTime(clock["evening_start"], s.clock.evening_start))
|
||||
{
|
||||
sendError(400, "Times must look like 08:30 (00:00 - 24:00)");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonVariantConst ha = body["home_assistant"];
|
||||
copyBool(ha["enabled"], s.home_assistant.enabled);
|
||||
copyText(ha["host"], s.home_assistant.host, sizeof(s.home_assistant.host));
|
||||
if (ha["port"].is<int>())
|
||||
s.home_assistant.port = ha["port"].as<int>();
|
||||
copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user));
|
||||
copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password));
|
||||
|
||||
bool restart = false;
|
||||
const char *error = appSaveSettings(s, restart);
|
||||
if (error)
|
||||
{
|
||||
sendError(400, error);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
settingsToJson(appSettings(), doc["settings"].to<JsonObject>());
|
||||
doc["restart"] = restart;
|
||||
sendJson(200, doc);
|
||||
if (restart)
|
||||
appRestartLater(1000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void handleRestart()
|
||||
{
|
||||
sendOk();
|
||||
appRestartLater(500);
|
||||
}
|
||||
|
||||
static void handleRecovery()
|
||||
{
|
||||
if (!recoveryAvailable())
|
||||
{
|
||||
sendError(409, "No recovery app installed (see README, one-time USB installation)");
|
||||
return;
|
||||
}
|
||||
sendOk();
|
||||
delay(300); // let the response reach the browser
|
||||
rebootToRecovery(RecoveryReason::Manual);
|
||||
}
|
||||
|
||||
void webUiBegin()
|
||||
{
|
||||
server.on("/", HTTP_GET, handleRoot);
|
||||
server.on("/api/status", HTTP_GET, handleStatus);
|
||||
server.on("/api/screen", HTTP_GET, handleScreen);
|
||||
server.on("/api/bitmaps", HTTP_GET, handleBitmaps);
|
||||
server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
|
||||
server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
|
||||
server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete);
|
||||
server.on("/api/page", HTTP_POST, handlePage);
|
||||
server.on("/api/live", HTTP_POST, handleLive);
|
||||
server.on("/api/live/stop", HTTP_POST, handleLiveStop);
|
||||
server.on("/api/settings", HTTP_GET, handleSettingsGet);
|
||||
server.on("/api/settings", HTTP_POST, handleSettingsPost);
|
||||
server.on("/api/restart", HTTP_POST, handleRestart);
|
||||
server.on("/api/recovery", HTTP_POST, handleRecovery);
|
||||
server.onNotFound([]() { sendError(404, "Not found"); });
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void webUiLoop()
|
||||
{
|
||||
server.handleClient();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display,
|
||||
// choosing the page, bitmaps (upload, draw, store), live mode, settings and Home
|
||||
// Assistant. The page itself is in web/web_page.h.
|
||||
//
|
||||
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
|
||||
// GET /api/status time, WiFi, firmware, Home Assistant, storage
|
||||
// GET /api/screen what the dots show now {on, live, data}
|
||||
// GET /api/bitmaps all bitmaps with data {storage, bitmaps:[{name, builtin, data}]}
|
||||
// GET /api/bitmap?name= one bitmap {name, data}
|
||||
// POST /api/bitmap store {name, data}
|
||||
// POST /api/bitmap/delete {name}
|
||||
// POST /api/page {page: "clock" or a bitmap name}
|
||||
// POST /api/live show now {data}
|
||||
// POST /api/live/stop
|
||||
// GET /api/settings (the MQTT password is never sent, only password_set)
|
||||
// POST /api/settings changed fields; {settings, restart}
|
||||
// POST /api/restart
|
||||
// POST /api/recovery
|
||||
void webUiBegin();
|
||||
void webUiLoop();
|
||||
Reference in new issue
Block a user