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:
mangavalkandClaude Opus 5.5 committed 2026-10-13 01:52:34 +02:00
1 parent c9e0800e36
commit 2b1ac12ed7
34 files changed
+1989 -117

No files matched your search

+188
View File
@@ -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"> &micro;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";
+403
View File
@@ -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();
}
+22
View File
@@ -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();