Add dynamic pages with clock, date, Home Assistant values and fonts

The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page
has a background bitmap per time of day and up to 8 elements (clock, date,
Home Assistant value, text), each with position, alignment, font and overlay.
Pages are drawn on the ESP32, also for the web previews.

- Pages tab in the web UI: page editor with drag and drop, font upload
  (TTF/OTF converted in the browser to the FDF1 dot font format)
- Built-in fonts Small, Normal, Large and Full height (generated by
  tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning
- Home Assistant values over the REST API (URL and token in the HA tab)
- Settings version 4: evening settings removed (now time slots of the Clock
  page), old records are converted
- HA Mode dropdown lists the dynamic pages and bitmaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 19:43:59 +02:00
1 parent 1d3b084de1
commit 0411804a2f
49 files changed
+3058 -331

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
#include "web/page_json.h"
#include <Arduino.h>
#include <string.h>
#include "logic/settings.h"
static const char *const TYPES[] = {"clock", "date", "ha", "text"};
static const char *const ALIGNS[] = {"left", "center", "right"};
static const char *const OVERLAYS[] = {"top", "box", "invert"};
static const char *const LANGUAGES[] = {"nl", "en"};
template <size_t N> static int indexOf(const char *const (&names)[N], const char *name)
{
for (size_t i = 0; i < N; i++)
if (name && strcmp(names[i], name) == 0)
return i;
return -1;
}
void pageToJson(const DynamicPage &page, JsonObject out)
{
char time[6];
JsonArray slots = out["slots"].to<JsonArray>();
for (int i = 0; i < page.slot_count; i++)
{
JsonObject slot = slots.add<JsonObject>();
formatTime(page.slots[i].start, time);
slot["start"] = String(time);
slot["bitmap"] = page.slots[i].bitmap;
}
JsonArray elements = out["elements"].to<JsonArray>();
for (int i = 0; i < page.element_count; i++)
{
const PageElement &e = page.elements[i];
JsonObject o = elements.add<JsonObject>();
o["type"] = TYPES[(int)e.type];
o["x"] = e.x;
o["y"] = e.y;
o["align"] = ALIGNS[(int)e.align];
o["overlay"] = OVERLAYS[(int)e.overlay];
o["font"] = e.font;
o["format"] = e.format;
o["language"] = LANGUAGES[(int)e.language];
o["decimals"] = e.decimals;
o["text"] = e.text;
o["suffix"] = e.suffix;
o["entity"] = e.entity;
o["attribute"] = e.attribute;
}
}
// False when the text does not fit
static bool copyText(JsonVariantConst value, char *out, size_t size)
{
const char *text = value | "";
if (strlen(text) >= size)
return false;
strcpy(out, text);
return true;
}
const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
{
page = DynamicPage();
JsonArrayConst slots = in["slots"];
JsonArrayConst elements = in["elements"];
if (slots.size() > (size_t)PAGE_SLOTS)
return "Too many time slots (8 at most)";
if (elements.size() > (size_t)PAGE_ELEMENTS)
return "Too many elements (8 at most)";
for (JsonVariantConst s : slots)
{
PageSlot &slot = page.slots[page.slot_count++];
if (!parseTime(s["start"] | "", slot.start))
return "Times must look like 08:30";
if (!copyText(s["bitmap"], slot.bitmap, sizeof(slot.bitmap)))
return "Bitmap name too long";
}
for (JsonVariantConst o : elements)
{
PageElement &e = page.elements[page.element_count++];
int type = indexOf(TYPES, o["type"]), align = indexOf(ALIGNS, o["align"] | "left"),
overlay = indexOf(OVERLAYS, o["overlay"] | "top"), language = indexOf(LANGUAGES, o["language"] | "nl");
if (type < 0 || align < 0 || overlay < 0 || language < 0)
return "Unknown element setting";
e.type = (ElementType)type;
e.align = (Align)align;
e.overlay = (Overlay)overlay;
e.language = (Language)language;
// Range checks are in validatePage(); out of range numbers must not wrap
e.x = constrain(o["x"] | 0, -1000, 1000);
e.y = constrain(o["y"] | 0, -1000, 1000);
e.format = constrain(o["format"] | 0, 0, 255);
e.decimals = constrain(o["decimals"] | (int)DECIMALS_AS_IS, -100, 100);
if (!copyText(o["font"], e.font, sizeof(e.font)) || !copyText(o["text"], e.text, sizeof(e.text)) ||
!copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
return "Text too long (32 characters, unit 16, entity 64)";
}
return validatePage(page);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <ArduinoJson.h>
#include "logic/page.h"
// Dynamic pages (logic/page.h) as JSON for the web UI:
// {slots: [{start: "08:00", bitmap: "Pikachu"}],
// elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right",
// overlay: "top"|"box"|"invert", font, format, language: "nl"|"en",
// decimals (-1 = as received), text, suffix, entity, attribute}]}
void pageToJson(const DynamicPage &page, JsonObject out);
// Returns nullptr or a message for the user. Validates the page (validatePage()).
const char *pageFromJson(JsonVariantConst in, DynamicPage &page);
+180 -40
View File
@@ -2,14 +2,16 @@
#include <pgmspace.h>
// The web UI: one page, the browser does the image work (conversion, editor, preview).
// API: see web/web_ui.cpp. Bitmaps are 84 x 19, as 418 hex digits in drawBitmap format.
// The web UI: one page, the browser does the image work (conversion, editor, font
// conversion). Dynamic pages are drawn by the ESP32 (/api/pages/preview), so the preview
// is exactly what the display shows. API: see web/web_ui.h. Bitmaps are 84 x 19, as 418
// hex digits in drawBitmap format.
static const char WEB_PAGE[] PROGMEM = R"rawliteral(<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FlipDot Display</title>
<style>
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b}
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b;--sel:#4da3ff}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--text)}
header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}
header h1{font-size:18px;margin:0}#statusline{color:var(--dim);font-size:13px}
@@ -21,19 +23,21 @@ nav button{background:var(--card);border:1px solid var(--line);color:var(--text)
nav button.on{border-color:var(--accent);color:var(--accent)}
section{display:none}section.on{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
h2{font-size:15px;margin:0 0 10px}
h2{font-size:15px;margin:0 0 10px}h3{font-size:13px;margin:14px 0 6px;color:var(--dim)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
.item{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer}
.item.cur{border-color:var(--accent)}.item canvas{width:100%;background:#000;border-radius:4px}
.item .name{font-size:13px;margin:6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .tag{color:var(--dim);font-size:11px}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0}
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px}
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px;max-width:100%}
input[type=checkbox]{transform:scale(1.2)}input[type=range]{padding:0}
button.b{background:var(--accent);color:#000;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 10px;cursor:pointer}
button.d{color:var(--bad)}
#editor{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
#f_canvas{width:100%;background:#000;border-radius:8px}
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
.hint{color:var(--dim);font-size:12px}#msg{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px;display:none}
</style></head><body>
@@ -50,6 +54,37 @@ button.d{color:var(--bad)}
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</p></div></section>
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
<p class="hint">A background per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.</p></div>
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
<label>Preview at <input type="time" id="pe_time"></label></div>
<canvas id="pe_canvas" width="840" height="190"></canvas>
<p class="hint" id="pe_err"></p>
<h3>Background</h3><div id="pe_slots"></div>
<div class="row"><button class="s" onclick="peAddSlot()">Add time slot</button></div>
<p class="hint">Each background is shown from its time until the next one; the last one continues after midnight.</p>
<h3>Elements</h3><div id="pe_elems"></div>
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button></div>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab.</p></div>
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
<h3>Add a font</h3>
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
<div class="row" id="f_opts" style="display:none">
<label>Height <input type="number" id="f_h" min="4" max="19" value="10" style="width:4.5em"> dots</label>
<label>Size <input type="number" id="f_size" min="4" max="80" value="12" style="width:4.5em"> px</label>
<label>Baseline <input type="number" id="f_base" min="1" max="19" value="8" style="width:4.5em"></label>
<label>Spacing <input type="number" id="f_sp" min="0" max="4" value="1" style="width:4em"></label>
<label>Threshold <input type="range" id="f_thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="f_mono" checked> equal width digits</label></div>
<div id="f_prev" style="display:none"><canvas id="f_canvas" width="840" height="190"></canvas>
<div class="row"><input id="f_sample" value="12:34 do 15 okt" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24">
<button class="b" onclick="fontSave()">Save font</button></div></div>
<p class="hint">Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).</p></div></section>
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<p class="hint" id="storage"></p></div></section>
@@ -74,10 +109,9 @@ button.d{color:var(--bad)}
<label>Quarter hours</label><label><input type="checkbox" id="s_q"> late evening: only at :00 :15 :30 :45</label>
<label>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
</div></div>
<div class="card"><h2>Clock</h2><div class="form">
<label>Yawning Pikachu</label><label><input type="checkbox" id="c_ev"> from <input type="time" id="c_evt"></label>
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" when WiFi is down</label>
</div></div>
<div class="card"><h2>Without WiFi</h2><div class="form">
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" instead of a dynamic page when WiFi is down</label>
</div><p class="hint">The backgrounds per time of day (like the yawning Pikachu) are set per page in the Pages tab.</p></div>
<div class="card"><h2>Hardware</h2><div class="form">
<label>Flip time</label><label><input type="number" id="h_flip" min="50" max="2000" style="width:6em"> &micro;s (50-2000)</label>
<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>
@@ -104,8 +138,12 @@ button.d{color:var(--bad)}
<label>Port</label><input type="number" id="ha_port" min="1" max="65535" style="width:7em">
<label>User</label><input id="ha_user" autocomplete="off">
<label>Password</label><input type="password" id="ha_pass" placeholder="unchanged" autocomplete="new-password">
</div><p class="hint">Saving restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p>
<button class="b" onclick="saveHa()">Save and restart</button></div></section>
</div><p class="hint">Changing these restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, Recovery button.</p></div>
<div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form">
<label>URL</label><input id="ha_url" placeholder="empty: http://&lt;broker&gt;:8123">
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute.</p></div>
<button class="b" onclick="saveHa()">Save</button></section>
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
<div class="row" style="margin-top:12px"><button class="s" onclick="post('/api/restart').then(()=>msg('Restarting...'))">Restart</button>
@@ -114,21 +152,22 @@ button.d{color:var(--bad)}
</main><div id="msg"></div>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[];
const tabs=[['show','Show'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[];
const tabs=[['show','Show'],['pages','Pages'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
async function api(p,body){const r=await fetch(p,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));if(!r.ok){msg(j.error||('Error '+r.status),true);throw j}return j}
const post=(p,b)=>api(p,b||{});
const same=(a,b)=>!!a&&!!b&&a.toLowerCase()==b.toLowerCase();
function mk(tag,props,...kids){const e=document.createElement(tag);Object.assign(e,props||{});e.append(...kids);return e}
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
const hm=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
// mirror of the display
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
@@ -141,28 +180,128 @@ function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Las
['HA power switch',status.power_enabled?'on':'off'],
['Storage',status.storage.available?Math.round(status.storage.used/1024)+' of '+Math.round(status.storage.total/1024)+' KB used':'not available']];
$('sysinfo').innerHTML='';r.forEach(([k,v])=>{const a=document.createElement('label');a.textContent=k;const b=document.createElement('span');b.textContent=v;$('sysinfo').append(a,b)})}
// pages and bitmaps
async function loadBitmaps(){const l=await api('/api/bitmaps');bitmapList=l.bitmaps;
$('storage').textContent=l.storage?'':'Bitmap storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';
// pages, bitmaps and fonts
async function loadLists(){const[b,p,f]=await Promise.all([api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';dyn.innerHTML='';
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
pages.appendChild(card('Clock','Pikachu with the time',null,()=>selectPage('clock'),settings&&settings.page=='clock'));
for(const bm of bitmapList){const data=fromHex(bm.data);bm.bits=data;
pages.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectPage(bm.name),settings&&settings.page==bm.name));
offs.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectOff(bm.name),settings&&settings.off_page==bm.name));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=document.createElement('div');r.className='row';
for(const pg of pageList){const tag=pg.builtin?'built-in page':'page';
pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name)));
const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false)));
if(!pg.builtin)r.append(btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
pages.appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
offs.appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}}
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}
const fl=$('fontlist');fl.innerHTML='';
for(const f of fontList)fl.append(mk('label',{textContent:f.name}),mk('span',{},f.height+' dots high'+(f.builtin?', built-in ':' '),f.builtin?'':btn('Delete',()=>delFont(f.name),'d')));
if(pe)peBuild()}
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
function card(name,tag,bits,onclick,cur){const d=document.createElement('div');d.className='item'+(cur?' cur':'');
if(bits)d.appendChild(mini(bits));else{const c=document.createElement('div');c.style.cssText='height:76px;display:flex;align-items:center;justify-content:center;font-size:28px;background:#000;border-radius:4px;color:#ffd21f';c.textContent='12:34';d.appendChild(c)}
const n=document.createElement('div');n.className='name';n.textContent=name;const t=document.createElement('div');t.className='tag';t.textContent=tag;d.append(n,t);
if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadBitmaps()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadBitmaps()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadBitmaps()}
function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits));
d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// page editor: the ESP32 draws the preview and returns the element boxes
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text'};
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:''}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
function peClose(){pe=null;$('pe').style.display='none'}
function peBuild(){peSlots();peElems()}
function field(label,input){return mk('label',{},label+' ',input)}
function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name]));
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',opts),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
function peElems(){const d=$('pe_elems');d.innerHTML='';
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
if(e.type=='clock')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05']]))));
if(e.type=='date')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems))));
if(e.type=='ha'){rows.push(mk('div',{className:'row'},field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e))));
rows.push(mk('div',{className:'row'},field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']]))))}
if(e.type=='text')rows.push(mk('div',{className:'row'},field('Text',inp(e,'text','text',{size:24,maxLength:32}))));
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
rows.push(mk('div',{className:'row'},field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'})),
field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),
field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]))));
box.append(...rows);d.append(box)})}
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
async function peRender(){if(!pe)return;const seq=++peSeq,pos=pe.page.elements.map(e=>[e.x,e.y]),body={page:pePage()};if($('pe_time').value)body.time=$('pe_time').value;
let r,j;try{r=await fetch('/api/pages/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});j=await r.json()}catch(e){j={error:'No connection'}}
if(!pe||seq!=peSeq)return;
if(!r||!r.ok){$('pe_err').textContent=j.error||'Error';$('pe_err').style.color='var(--bad)';return}
$('pe_err').textContent='';peData=fromHex(j.data);pe.boxes=j.boxes;pe.pos=pos;peDraw()}
// the boxes are moved by what changed since the preview was drawn (while dragging)
function peDraw(){if(!pe)return;const c=$('pe_canvas'),g=c.getContext('2d'),s=c.width/W;draw(c,peData||new Uint8Array(W*H));g.lineWidth=2;
pe.boxes.forEach((b,i)=>{const e=pe.page.elements[i],p=pe.pos[i];if(!e||!p)return;const x=b[0]+e.x-p[0],y=b[1]+e.y-p[1];
g.strokeStyle=i==pe.sel?'#4da3ff':'rgba(255,255,255,.35)';g.setLineDash(i==pe.sel?[]:[6,4]);g.strokeRect(x*s-2,y*s-2,Math.max(b[2],1)*s+4,Math.max(b[3],1)*s+4)});g.setLineDash([])}
const pc=$('pe_canvas');
function pcPos(e){const r=pc.getBoundingClientRect();return[(e.clientX-r.left)/r.width*W,(e.clientY-r.top)/r.height*H]}
pc.onpointerdown=e=>{if(!pe)return;const[x,y]=pcPos(e);let hit=-1;
pe.boxes.forEach((b,i)=>{const el=pe.page.elements[i],p=pe.pos[i];if(!el||!p)return;const bx=b[0]+el.x-p[0],by=b[1]+el.y-p[1];
if(x>=bx-1&&x<=bx+b[2]+1&&y>=by-1&&y<=by+b[3]+1)hit=i});
pe.sel=hit;peMark();if(hit<0)return;const el=pe.page.elements[hit];peDrag={i:hit,x0:x,y0:y,ex:el.x,ey:el.y};pc.setPointerCapture(e.pointerId)};
pc.onpointermove=e=>{if(!peDrag)return;const[x,y]=pcPos(e),el=pe.page.elements[peDrag.i],nx=peDrag.ex+Math.round(x-peDrag.x0),ny=peDrag.ey+Math.round(y-peDrag.y0);
if(nx==el.x&&ny==el.y)return;el.x=nx;el.y=ny;peDraw();pePreview()};
pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()};
document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];
if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()});
async function haTest(e){const r=await api('/api/ha/value?entity='+encodeURIComponent(e.entity)+'&attribute='+encodeURIComponent(e.attribute));msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+r.value);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
async function peLive(){if(peData)await live(peData)}
// fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h)
let fontFam=null,fontData=null;
$('f_file').onchange=async e=>{const f=e.target.files[0];if(!f)return;
try{const ff=new FontFace('upload'+Date.now(),await f.arrayBuffer());await ff.load();document.fonts.add(ff);fontFam=ff.family}catch(err){msg('Could not read this font',true);return}
if(!$('f_name').value)$('f_name').value=f.name.replace(/\.[^.]*$/,'').replace(/[^A-Za-z0-9 _-]/g,'').trim().slice(0,24);
$('f_opts').style.display='flex';$('f_prev').style.display='block';fontConvert()};
['f_size','f_base','f_thr','f_sp','f_mono'].forEach(id=>$(id).oninput=fontConvert);$('f_sample').oninput=()=>fontData&&fontPreview();
$('f_h').oninput=()=>{const h=+$('f_h').value;$('f_base').value=Math.round(h*0.8);$('f_size').value=Math.round(h*1.25);fontConvert()};
function fontConvert(){if(!fontFam)return;const h=Math.min(19,Math.max(4,Math.round(+$('f_h').value||10))),size=+$('f_size').value||h,base=+$('f_base').value||h,thr=+$('f_thr').value;
const c=document.createElement('canvas');c.width=Math.ceil(size*2)+16;c.height=h;const g=c.getContext('2d',{willReadFrequently:true});g.font=size+'px "'+fontFam+'"';g.textBaseline='alphabetic';
const glyphs=[];
for(let k=32;k<127;k++){const ch=String.fromCharCode(k);g.clearRect(0,0,c.width,h);g.fillStyle='#fff';g.fillText(ch,8,base);
const d=g.getImageData(0,0,c.width,h).data,on=(x,y)=>d[(y*c.width+x)*4+3]>thr;let x0=c.width,x1=-1;
for(let x=0;x<c.width;x++)for(let y=0;y<h;y++)if(on(x,y)){x0=Math.min(x0,x);x1=Math.max(x1,x)}
let w=x1<0?0:Math.min(32,x1-x0+1);const blank=k==32;if(blank)w=Math.min(32,Math.max(1,Math.round(g.measureText(' ').width)));
const rows=[];for(let y=0;y<h;y++){const r=[];for(let x=0;x<w;x++)r.push(!blank&&on(x0+x,y)?1:0);rows.push(r)}glyphs.push({w,rows})}
if($('f_mono').checked){const dg=glyphs.slice(16,26),mw=Math.max(...dg.map(x=>x.w));
dg.forEach(x=>{const l=Math.floor((mw-x.w)/2),r=mw-x.w-l;x.rows=x.rows.map(row=>Array(l).fill(0).concat(row,Array(r).fill(0)));x.w=mw})}
const sp=Math.min(4,Math.max(0,Math.round(+$('f_sp').value||0))),bytes=[70,68,70,49,h,sp,32,95].concat(glyphs.map(x=>x.w));
glyphs.forEach(x=>x.rows.forEach(row=>{for(let i=0;i<x.w;i+=8){let v=0;for(let b=0;b<8;b++)if(row[i+b])v|=0x80>>b;bytes.push(v)}}));
fontData={bytes,glyphs,h,sp};fontPreview()}
function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(const ch of $('f_sample').value){const k=ch.charCodeAt(0)-32;if(k<0||k>94)continue;const gl=fontData.glyphs[k];if(!gl.w)continue;if(!first)x+=fontData.sp;first=false;
for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w}
draw($('f_canvas'),b)}
async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists()}
// editor
const ed={bits:new Uint8Array(W*H),c:$('editor'),set(b){this.bits=b;draw(this.c,b)}};ed.set(ed.bits);
let painting=null,liveTimer=null,srcImg=null;
@@ -175,7 +314,7 @@ function edClear(){ed.set(new Uint8Array(W*H));changed()}
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadBitmaps()}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
// image conversion
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
@@ -198,20 +337,21 @@ async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(
async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()}
// settings
function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;$('s_late').value=s.schedule.late_start;
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;$('c_ev').checked=s.clock.evening_enabled;$('c_evt').value=s.clock.evening_start;
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none'}
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'}
function collect(){const s=JSON.parse(JSON.stringify(settings));
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
Object.assign(s.clock,{evening_enabled:$('c_ev').checked,evening_start:$('c_evt').value,no_wifi_screen:$('c_nw').checked});
s.clock={no_wifi_screen:$('c_nw').checked};
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim()});
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;return s}
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s}
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
async function saveHa(){await saveSettings()}
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadBitmaps()}
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadLists()}
start();
</script></body></html>)rawliteral";
+221 -22
View File
@@ -4,14 +4,21 @@
#include <WebServer.h>
#include <WiFi.h>
#include <time.h>
#include <vector>
#include "app.h"
#include "config.h"
#include "display/bitmap_store.h"
#include "display/font_store.h"
#include "display/page_resources.h"
#include "display/page_store.h"
#include "logic/bitmap_format.h"
#include "logic/page_render.h"
#include "net/ha_api.h"
#include "net/home_assistant.h"
#include "system/boot_target.h"
#include "system/firmware_guard.h"
#include "web/page_json.h"
#include "web/web_page.h"
static WebServer server(80);
@@ -70,6 +77,54 @@ static bool copyTime(JsonVariantConst value, uint16_t &out)
return parseTime(value.as<const char *>(), out);
}
static void sendResult(const char *error)
{
if (error)
sendError(400, error);
else
sendOk();
}
static int hexDigit(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
// Any even number of hex digits, at most max_size bytes
static bool fromHex(const char *hex, std::vector<uint8_t> &out, size_t max_size)
{
size_t length = strlen(hex);
if (length % 2 || length / 2 > max_size)
return false;
out.resize(length / 2);
for (size_t i = 0; i < out.size(); i++)
{
int high = hexDigit(hex[2 * i]), low = hexDigit(hex[2 * i + 1]);
if (high < 0 || low < 0)
return false;
out[i] = high << 4 | low;
}
return true;
}
// The time pages are drawn for in previews: now, or 12:34 before the time is synced
static LocalTime previewTime()
{
LocalTime time;
if (!appLocalTime(time))
{
time.hour = 12;
time.minute = 34;
}
return time;
}
// ---------------------------------------------------------------------------
// Page and status
// ---------------------------------------------------------------------------
@@ -195,14 +250,7 @@ static void handleBitmapSave()
sendError(400, "Invalid bitmap data");
return;
}
const char *error = saveBitmap(name, bitmap);
if (error)
{
sendError(400, error);
return;
}
appBitmapChanged(name);
sendOk();
sendResult(appSaveBitmap(name, bitmap));
}
static void handleBitmapDelete()
@@ -210,18 +258,162 @@ static void handleBitmapDelete()
JsonDocument body;
if (!readBody(body))
return;
const char *name = body["name"] | "";
const char *error = deleteBitmap(name);
sendResult(appDeleteBitmap(body["name"] | ""));
}
// ---------------------------------------------------------------------------
// Dynamic pages
// ---------------------------------------------------------------------------
// Streamed like the bitmaps, each with a preview drawn for the current time
static void handlePages()
{
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
server.sendContent("{\"pages\":[");
bool first = true;
DynamicPage *page = new DynamicPage;
LocalTime time = previewTime();
uint8_t bitmap[BITMAP_BYTES];
char hex[BITMAP_HEX_LENGTH + 1];
for (const PageInfo &info : listPages())
{
if (!loadPage(info.name.c_str(), *page))
continue;
renderPage(*page, time, pageResources, bitmap);
bitmapToHex(bitmap, hex);
JsonDocument doc;
doc["name"] = info.name;
doc["builtin"] = info.builtin;
doc["data"] = hex;
pageToJson(*page, doc["page"].to<JsonObject>());
String item = first ? "" : ",";
serializeJson(doc, item);
server.sendContent(item);
first = false;
}
delete page;
server.sendContent("]}");
server.sendContent("");
}
static void handlePageSave()
{
JsonDocument body;
if (!readBody(body))
return;
DynamicPage *page = new DynamicPage;
const char *error = pageFromJson(body["page"], *page);
if (!error)
error = appSavePage(body["name"] | "", *page);
delete page;
sendResult(error);
}
static void handlePageDelete()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appDeletePage(body["name"] | ""));
}
// Draws a page from the editor: {page, time (optional "HH:MM")} -> {data, boxes: [[x, y, w, h]]}
static void handlePagePreview()
{
JsonDocument body;
if (!readBody(body))
return;
DynamicPage *page = new DynamicPage;
const char *error = pageFromJson(body["page"], *page);
LocalTime time = previewTime();
uint16_t minutes;
if (!error && body["time"].is<const char *>() && parseTime(body["time"], minutes))
{
time.hour = minutes / 60 % 24;
time.minute = minutes % 60;
}
if (error)
{
delete page;
sendError(400, error);
return;
}
uint8_t bitmap[BITMAP_BYTES];
ElementBox boxes[PAGE_ELEMENTS];
renderPage(*page, time, pageResources, bitmap, boxes);
char hex[BITMAP_HEX_LENGTH + 1];
bitmapToHex(bitmap, hex);
JsonDocument doc;
doc["data"] = hex;
JsonArray list = doc["boxes"].to<JsonArray>();
for (int i = 0; i < page->element_count; i++)
{
JsonArray box = list.add<JsonArray>();
box.add(boxes[i].x);
box.add(boxes[i].y);
box.add(boxes[i].width);
box.add(boxes[i].height);
}
delete page;
sendJson(200, doc);
}
// ---------------------------------------------------------------------------
// Fonts and Home Assistant values
// ---------------------------------------------------------------------------
static void handleFonts()
{
JsonDocument doc;
JsonArray list = doc["fonts"].to<JsonArray>();
for (const FontInfo &info : listFonts())
{
JsonObject font = list.add<JsonObject>();
font["name"] = info.name;
font["builtin"] = info.builtin;
font["height"] = info.height;
}
sendJson(200, doc);
}
static void handleFontSave()
{
JsonDocument body;
if (!readBody(body))
return;
std::vector<uint8_t> data;
if (!fromHex(body["data"] | "", data, FONT_MAX_SIZE))
{
sendError(400, "Invalid font data");
return;
}
sendResult(appSaveFont(body["name"] | "", data.data(), data.size()));
}
static void handleFontDelete()
{
JsonDocument body;
if (!readBody(body))
return;
sendResult(appDeleteFont(body["name"] | ""));
}
// Reads a value now (test button in the page editor); it is then also used in previews
static void handleHaValue()
{
String value;
const char *error = haApiFetchNow(server.arg("entity").c_str(), server.arg("attribute").c_str(), value);
if (error)
{
sendError(400, error);
return;
}
if (strcmp(appSettings().page, name) == 0)
appSelectPage(PAGE_CLOCK);
if (strcmp(appSettings().off_page, name) == 0)
appSelectOffPage(OFF_PAGE_BLANK);
sendOk();
JsonDocument doc;
doc["value"] = value;
sendJson(200, doc);
}
// ---------------------------------------------------------------------------
@@ -296,9 +488,6 @@ static void settingsToJson(const Settings &s, JsonObject o)
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>();
@@ -307,6 +496,8 @@ static void settingsToJson(const Settings &s, JsonObject o)
ha["port"] = s.home_assistant.port;
ha["user"] = s.home_assistant.user;
ha["password_set"] = s.home_assistant.password[0] != '\0';
ha["api_url"] = s.home_assistant.api_url;
ha["token_set"] = s.home_assistant.token[0] != '\0';
}
static void handleSettingsGet()
@@ -345,11 +536,9 @@ static void handleSettingsPost()
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);
copyBool(body["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))
!copyTime(schedule["night_start"], s.schedule.night_start))
{
sendError(400, "Times must look like 08:30 (00:00 - 24:00)");
return;
@@ -362,6 +551,8 @@ static void handleSettingsPost()
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));
copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url));
copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token));
bool restart = false;
const char *error = appSaveSettings(s, restart);
@@ -410,6 +601,14 @@ void webUiBegin()
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/pages", HTTP_GET, handlePages);
server.on("/api/pages/save", HTTP_POST, handlePageSave);
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
server.on("/api/pages/preview", HTTP_POST, handlePagePreview);
server.on("/api/fonts", HTTP_GET, handleFonts);
server.on("/api/fonts/save", HTTP_POST, handleFontSave);
server.on("/api/fonts/delete", HTTP_POST, handleFontDelete);
server.on("/api/ha/value", HTTP_GET, handleHaValue);
server.on("/api/page", HTTP_POST, handlePage);
server.on("/api/live", HTTP_POST, handleLive);
server.on("/api/live/stop", HTTP_POST, handleLiveStop);
+12 -4
View File
@@ -1,8 +1,8 @@
#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.
// choosing the page, dynamic pages and fonts, 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
@@ -11,10 +11,18 @@
// 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}
// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]}
// POST /api/pages/save {name, page} (page format: web/page_json.h)
// POST /api/pages/delete {name}
// POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]}
// GET /api/fonts {fonts:[{name, builtin, height}]}
// POST /api/fonts/save {name, data (hex, format of logic/font.h)}
// POST /api/fonts/delete {name}
// GET /api/ha/value?entity=&attribute= read a Home Assistant value now {value}
// POST /api/page {page: a dynamic page or bitmap name}
// POST /api/live show now {data}
// POST /api/live/stop
// GET /api/settings (the MQTT password is never sent, only password_set)
// GET /api/settings (the MQTT password and HA token are never sent, only *_set)
// POST /api/settings changed fields; {settings, restart}
// POST /api/restart
// POST /api/recovery