Add a page schedule with rotation, Home Assistant trigger rules and a Stopwatch tab

- Schedule: daily blocks from a start time, each with up to 6 rotating pages
  or bitmaps (own duration), switching with the effect or instantly. A manual
  pick (Show tab, HA Mode) lasts until the next block; Back to schedule ends it.
- Rules: an HA state (=, is not, above, below) shows a page while true, adds it
  to the rotation while true, or shows it for N seconds when it becomes true.
  Each rule is polled at its own interval, with a 60 s back-off on errors.
- PageSelector (pure logic, tested) decides the shown page; schedule stored in
  NVS; HA sensor Shown page and button Back to schedule.
- Web UI: Schedule and Stopwatch tabs, bitmaps used by pages or rules in their
  own subsection, schedule in export/import. Version 2.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:36:56 +02:00
1 parent 47ef65ee7d
commit 93b9bec69a
25 files changed
+1217 -50

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
#include "web/schedule_json.h"
#include <Arduino.h>
#include <string.h>
#include "logic/settings.h"
static const char *const COMPARES[] = {"eq", "ne", "gt", "lt"};
static const char *const MODES[] = {"while", "rotate", "for"};
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;
}
// 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;
}
void scheduleToJson(const PageSchedule &s, JsonObject out)
{
char time[6];
out["enabled"] = s.enabled;
JsonArray blocks = out["blocks"].to<JsonArray>();
for (int i = 0; i < s.block_count; i++)
{
const ScheduleBlock &b = s.blocks[i];
JsonObject o = blocks.add<JsonObject>();
formatTime(b.start, time);
o["start"] = String(time);
o["effect"] = b.effect;
JsonArray items = o["items"].to<JsonArray>();
for (int j = 0; j < b.item_count; j++)
{
JsonObject item = items.add<JsonObject>();
item["page"] = b.items[j].page;
item["seconds"] = b.items[j].seconds;
}
}
JsonArray rules = out["rules"].to<JsonArray>();
for (int i = 0; i < s.rule_count; i++)
{
const TriggerRule &r = s.rules[i];
JsonObject o = rules.add<JsonObject>();
o["entity"] = r.entity;
o["attribute"] = r.attribute;
o["compare"] = COMPARES[(int)r.compare];
o["value"] = r.value;
o["page"] = r.page;
o["mode"] = MODES[(int)r.mode];
o["seconds"] = r.seconds;
o["interval"] = r.interval;
}
}
const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
{
s = PageSchedule();
s.enabled = in["enabled"] | true;
JsonArrayConst blocks = in["blocks"];
JsonArrayConst rules = in["rules"];
if (blocks.size() > (size_t)SCHEDULE_BLOCKS)
return "Too many schedule blocks (8 at most)";
if (rules.size() > (size_t)TRIGGER_RULES)
return "Too many rules (8 at most)";
for (JsonVariantConst o : blocks)
{
ScheduleBlock &b = s.blocks[s.block_count++];
if (!parseTime(o["start"] | "", b.start))
return "Block times must look like 07:00";
b.effect = o["effect"] | true;
JsonArrayConst items = o["items"];
if (items.size() > (size_t)BLOCK_ITEMS)
return "6 pages per block at most";
for (JsonVariantConst i : items)
{
ScheduleItem &item = b.items[b.item_count++];
if (!copyText(i["page"], item.page, sizeof(item.page)))
return "Page name too long";
item.seconds = constrain(i["seconds"] | 30, 0, 65535);
}
}
for (JsonVariantConst o : rules)
{
TriggerRule &r = s.rules[s.rule_count++];
int compare = indexOf(COMPARES, o["compare"] | "eq"), mode = indexOf(MODES, o["mode"] | "while");
if (compare < 0 || mode < 0)
return "Unknown rule setting";
r.compare = (Compare)compare;
r.mode = (TriggerMode)mode;
if (!copyText(o["entity"], r.entity, sizeof(r.entity)) || !copyText(o["attribute"], r.attribute, sizeof(r.attribute)) ||
!copyText(o["value"], r.value, sizeof(r.value)) || !copyText(o["page"], r.page, sizeof(r.page)))
return "Rule text too long";
r.seconds = constrain(o["seconds"] | 30, 0, 65535);
r.interval = constrain(o["interval"] | 10, 0, 65535);
}
return validateSchedule(s);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <ArduinoJson.h>
#include "logic/page_schedule.h"
// The page schedule (logic/page_schedule.h) as JSON for the web UI:
// {enabled,
// blocks: [{start: "07:00", effect: true, items: [{page, seconds}]}],
// rules: [{entity, attribute, compare: "eq"|"ne"|"gt"|"lt", value, page,
// mode: "while"|"rotate"|"for", seconds, interval}]}
void scheduleToJson(const PageSchedule &schedule, JsonObject out);
// Returns nullptr or a message for the user. Validates (validateSchedule()).
const char *scheduleFromJson(JsonVariantConst in, PageSchedule &schedule);
+86 -14
View File
@@ -37,6 +37,7 @@ button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);bor
button.d{color:var(--bad)}
#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}
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom: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}
@@ -49,11 +50,12 @@ button.d{color:var(--bad)}
<section id="t-show"><div class="card"><h2>What to show during the day</h2>
<div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="row"><span id="shownnow" class="hint"></span><button class="s" id="backbtn" style="display:none" onclick="backToSchedule()">Back to schedule</button></div>
<div class="grid" id="pages"></div>
<div class="row"><b>Stopwatch</b> <span id="swtime">0:00</span><button class="s" onclick="stopwatch('start')">Start</button>
<button class="s" onclick="stopwatch('stop')">Stop</button><button class="s" onclick="stopwatch('reset')">Reset</button></div>
<p class="hint">The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.</p></div>
<details id="pages_used_box"><summary></summary><div class="grid" id="pages_used"></div></details>
<p class="hint">The display schedule (Settings) decides when the display is on; the Schedule tab what it shows when. With a schedule, a page chosen here (or with the Home Assistant "Mode" dropdown) is shown until the next block starts.</p></div>
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<details id="offpages_used_box"><summary></summary><div class="grid" id="offpages_used"></div></details>
<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>
@@ -89,7 +91,24 @@ button.d{color:var(--bad)}
<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-schedule"><div class="card"><h2>Now</h2>
<div class="row"><span id="sc_now"></span><button class="s" onclick="backToSchedule()">Back to schedule</button></div></div>
<div class="card"><h2>Schedule</h2>
<label><input type="checkbox" id="sc_en" onchange="sched.enabled=this.checked;schedDirty=true"> on</label>
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on.</p></div>
<button class="b" onclick="scSave()">Save schedule and rules</button></section>
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
<div class="row"><b id="swtime" style="font-size:32px;font-variant-numeric:tabular-nums">0:00</b></div>
<div class="row"><button class="b" onclick="stopwatch('start')">Start</button><button class="s" onclick="stopwatch('stop')">Stop</button>
<button class="s" onclick="stopwatch('reset')">Reset</button></div>
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
<p class="hint" id="storage"></p></div></section>
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
@@ -161,12 +180,12 @@ button.d{color:var(--bad)}
<div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button>
<button class="b" onclick="importSel()">Import selected</button></div></div>
<p class="hint" id="i_result"></p>
<p class="hint">Export: one JSON file with the settings and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.</p></div></section>
<p class="hint">Export: one JSON file with the settings, the schedule and rules, and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.</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=[],pageList=[],fontList=[];
const tabs=[['show','Show'],['pages','Pages'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['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){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)}
@@ -187,7 +206,9 @@ async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'
setTimeout(pollScreen,document.hidden?5000:1500)}
async function pollStatus(){try{status=await api('/api/status');
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
$('livebox').style.display=status.live?'flex':'none';swShow(status.stopwatch);sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
$('livebox').style.display=status.live?'flex':'none';swShow(status.stopwatch);
const now='Showing: '+status.shown+' ('+status.shown_status+')';$('sc_now').textContent=now;$('shownnow').textContent=now;
$('backbtn').style.display=/until/.test(status.shown_status)?'':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
['HA power switch',status.power_enabled?'on':'off'],
@@ -196,10 +217,18 @@ function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Las
// pages, bitmaps and fonts
// each list on its own: one that fails to load doesn't empty the others
async function getList(p,key){try{const j=await api(p);if(Array.isArray(j[key]))return j}catch(e){}msg('Could not load '+p,true);return{[key]:[]}}
async function loadLists(){const[b,p,f]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','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));
// Bitmaps that pages (backgrounds, images) or rules use: shown in their own subsection
function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
for(const p of pageList){(p.page.slots||[]).forEach(s=>add(s.bitmap));
(p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
async function loadLists(){const[b,p,f,sc]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};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='';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
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)));
@@ -207,11 +236,14 @@ async function loadLists(){const[b,p,f]=await Promise.all([getList('/api/bitmaps
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('Export',()=>exportItem('page',pg.name)),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 u=used.has(bm.name.toLowerCase());
$(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
$(u?'offpages_used':'offpages').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('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}
if(!bm.builtin)r.append(btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
schedBuild();
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()}
@@ -228,6 +260,40 @@ function swShow(sw){if(sw)$('swtime').textContent=swText(sw.ms)+(sw.running?' (r
async function stopwatch(action){swShow(await post('/api/stopwatch',{action}));if(pe)pePreview()}
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// schedule and rules
const RCMP=[['eq','='],['ne','is not'],['gt','above'],['lt','below']];
const RMODE=[['while','show it while true'],['rotate','add it to the rotation while true'],['for','show it for a while when it becomes true']];
function sinp(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;schedDirty=true};return i}
function ssel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));if(!opts.some(x=>x[0]==o[k]))s.append(mk('option',{value:o[k],textContent:o[k]+' (gone)'}));
s.value=o[k];s.onchange=()=>{o[k]=s.value;schedDirty=true;if(after)after()};return s}
function pageOpts(){return pageList.map(p=>[p.name,p.name]).concat(bitmapList.map(b=>[b.name,b.name+' (bitmap)']))}
function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
sched.blocks.forEach((b,i)=>{const t=sinp(b,'start','time');t.onchange=()=>{sched.blocks.sort(bySlot);schedDirty=true;schedBuild()};
const ef=mk('input',{type:'checkbox',checked:b.effect});ef.onchange=()=>{b.effect=ef.checked;schedDirty=true};
const box=mk('div',{className:'el'},mk('div',{className:'row'},mk('b',{textContent:'From'}),t,mk('label',{},ef,' switch with the refresh effect'),
btn('Remove block',()=>{sched.blocks.splice(i,1);schedDirty=true;schedBuild()},'d')));
b.items.forEach((it,j)=>box.append(mk('div',{className:'row'},ssel(it,'page',opts),'for',sinp(it,'seconds','number',{style:'width:5em',min:5,max:3600}),'s',
btn('Remove',()=>{b.items.splice(j,1);schedDirty=true;schedBuild()},'d'))));
if(b.items.length<6)box.append(mk('div',{className:'row'},btn('+ Page',()=>{b.items.push({page:opts.length?opts[0][0]:'',seconds:30});schedDirty=true;schedBuild()})));
d.append(box)});
if(!sched.blocks.length)d.append(mk('p',{className:'hint',textContent:'No blocks: the chosen page is shown.'}));
const rd=$('sc_rules');rd.innerHTML='';
sched.rules.forEach((ru,i)=>{const state=mk('span',{className:'hint'});
const secs=mk('span',{},'for',sinp(ru,'seconds','number',{style:'width:5em',min:1,max:3600}),'s');secs.style.display=ru.mode=='while'?'none':'';
rd.append(mk('div',{className:'el'},
mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}),
sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}),
btn('Test',async()=>{const v=await api('/api/ha/value?entity='+encodeURIComponent(ru.entity)+'&attribute='+encodeURIComponent(ru.attribute));state.textContent='now: '+v.value})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d'))))});
if(!sched.rules.length)rd.append(mk('p',{className:'hint',textContent:'No rules.'}))}
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',sched);schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
// page editor: the ESP32 draws the preview and returns the element boxes
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image'};
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
@@ -353,7 +419,7 @@ function backup(){return{format:'flipdot-backup',version:1,firmware:status?statu
async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}}
async function exportAll(){const sec=$('x_secrets').checked,out=backup();
const[s,b,p,f]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]);
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;out.schedule=await api('/api/schedule');
out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page}));
for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name));
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
@@ -374,6 +440,9 @@ function impShow(){const d=$('i_list');d.innerHTML='';impRows=[];const ha=(imp.s
if(imp.settings){d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
SGROUPS.forEach(([k,l])=>{const c=mk('input',{type:'checkbox',checked:false}),note=k=='mqtt'&&ha.password!==undefined?' (with password)':k=='haapi'&&ha.token!==undefined?' (with token)':'';
c.dataset.key=k;d.append(mk('div',{},mk('label',{},c,' '+l+note)))})}
if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule';
d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))}
// bitmaps and pages share their names, fonts have their own
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)),
takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n));
@@ -407,6 +476,9 @@ async function importSel(){if(!imp)return;const groups=new Set([...document.quer
if(body.page&&!pageList.concat(bitmapList).some(x=>same(x.name,body.page))){errors.push('Page "'+body.page+'" not found, not chosen');delete body.page}
if(body.off_page&&!bitmapList.some(x=>same(x.name,body.off_page))){errors.push('Off image "'+body.off_page+'" not found, not chosen');delete body.off_page}
if(Object.keys(body).length){try{const r=await post('/api/settings',body);settings=r.settings;restart=r.restart;fill();done++}catch(e){errors.push('Settings: '+(e.error||'failed'))}}}
if(groups.has('schedule')){const sc=JSON.parse(JSON.stringify(imp.schedule)),page=n=>to('p',n)||to('b',n)||n;
(sc.blocks||[]).forEach(b=>(b.items||[]).forEach(i=>i.page=page(i.page)));(sc.rules||[]).forEach(r=>r.page=page(r.page));
try{await post('/api/schedule',sc);done++}catch(e){errors.push('Schedule: '+(e.error||'failed'))}}
loadLists();
$('i_result').textContent=done+' imported'+(errors.length?'. Not imported: '+errors.join('; '):'')+(restart?'. Restarting for the MQTT settings...':'');
msg(errors.length?'Import finished with problems':'Import finished',errors.length>0)}
+43
View File
@@ -20,6 +20,7 @@
#include "system/boot_target.h"
#include "system/firmware_guard.h"
#include "web/page_json.h"
#include "web/schedule_json.h"
#include "web/web_page.h"
static WebServer server(80);
@@ -159,6 +160,10 @@ static void handleStatus()
doc["display_on"] = nano.isOn();
doc["live"] = controller.liveActive();
doc["page"] = appSettings().page;
doc["shown"] = appShownPage();
char why[80];
appShowStatus(why, sizeof(why));
doc["shown_status"] = why;
doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled();
JsonObject sw = doc["stopwatch"].to<JsonObject>();
@@ -450,6 +455,41 @@ static void handleHaValue()
// Page and live mode
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Schedule and rules
// ---------------------------------------------------------------------------
static void handleScheduleGet()
{
JsonDocument doc;
scheduleToJson(appSchedule(), doc.to<JsonObject>());
sendJson(200, doc);
}
static void handleScheduleSave()
{
JsonDocument body;
if (!readBody(body))
return;
PageSchedule *schedule = new PageSchedule;
const char *error = scheduleFromJson(body.as<JsonVariantConst>(), *schedule);
if (!error)
error = appSaveSchedule(*schedule);
delete schedule;
if (error)
{
sendError(400, error);
return;
}
handleScheduleGet();
}
static void handleScheduleBack()
{
appBackToSchedule();
sendOk();
}
static void handlePage()
{
JsonDocument body;
@@ -672,6 +712,9 @@ void webUiBegin()
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/schedule", HTTP_GET, handleScheduleGet);
server.on("/api/schedule", HTTP_POST, handleScheduleSave);
server.on("/api/schedule/back", HTTP_POST, handleScheduleBack);
server.on("/api/live", HTTP_POST, handleLive);
server.on("/api/live/stop", HTTP_POST, handleLiveStop);
server.on("/api/stopwatch", HTTP_POST, handleStopwatch);
+5 -1
View File
@@ -20,7 +20,11 @@
// 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/page {page: a dynamic page or bitmap name} (with a schedule:
// shown until the next block)
// GET /api/schedule schedule and rules (format: web/schedule_json.h)
// POST /api/schedule replace them; returns them
// POST /api/schedule/back end a manual pick
// POST /api/live show now {data}
// POST /api/live/stop
// POST /api/stopwatch {action: "start" | "stop" | "reset"} -> {running, ms}