Public Access
The stopwatch counted with millis(), so it started at 0 after every restart and would overflow after 49 days. It now keeps the real start time (UTC ms) and the time counted before a pause, stored in NVS (system/stopwatch_store) on every change, so it keeps counting while the display is off or restarting. Stop pauses, Start continues, and the Stopwatch tab can set the start to a date and time (local, summer time by mktime) to count from, e.g. days since an event. Start, stop and set need the synced time. New page formats: 12d 4:05:09 and 3w 2d. Version 2.19.0, tested on the display over OTA (set start, future and invalid dates refused, pause, restart). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
716 lines
82 KiB
C++
716 lines
82 KiB
C++
#pragma once
|
|
|
|
#include <pgmspace.h>
|
|
|
|
// 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;--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}
|
|
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}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;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,#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}
|
|
#ic_canvas{max-width:100%;height:auto;display:block;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
|
|
.icbox{height:64px;display:flex;align-items:center;justify-content:center;background:#000;border-radius:4px}
|
|
.item .icbox canvas{width:auto;height:auto;max-width:100%;max-height:64px;border-radius:0}
|
|
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}
|
|
</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="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>
|
|
<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>
|
|
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
|
|
<p class="hint">A background (a bitmap or an icon) 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>Refresh effect</h3><div class="row"><select id="pe_effect" onchange="pe.page.effect=+this.value"></select></div>
|
|
<p class="hint">Used when this page appears and for its own changes (like the clock's minutes). A schedule block that switches instantly and rules still switch instantly; "small changes only" and the effect time are in Settings, Hardware.</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>
|
|
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
|
|
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button><button class="s" onclick="peAdd('life')">+ Game of Life</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. Type ° for degrees. Entity ids can contain {plex_user} (Home Assistant tab). A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</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 17.5°C" 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-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 (when ticked) over a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</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"><span id="swsince" class="hint"></span></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>
|
|
<div class="row"><label>Counting since <input type="datetime-local" id="swstart"></label><button class="s" onclick="swSetStart()">Set start</button></div>
|
|
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). It counts in real time from its start, also while the display is off or restarting. Stop pauses it, Start continues. Set start: counts from that date and time on (e.g. days since an event). 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>
|
|
<p class="hint">Bitmaps fill the whole display and can be shown on their own. A bitmap that is only part of pages can be made an icon (Make icon).</p></div></section>
|
|
|
|
<section id="t-icons"><div class="card"><h2>Icons</h2><div class="grid" id="icons"></div>
|
|
<div class="row"><button class="b" onclick="icNew()">New icon</button></div>
|
|
<p class="hint">Small images for dynamic pages: as an Image element or as a background (drawn at the top left). They are never shown on their own, so they don't appear in the lists of pages. Make bitmap turns one into a full screen bitmap.</p></div>
|
|
<div class="card" id="ic" style="display:none"><h2 id="ic_title">Icon</h2>
|
|
<div class="row"><input id="ic_name" placeholder="name" maxlength="24"><button class="b" onclick="icSave()">Save</button>
|
|
<button class="s" onclick="icLive()">Show on display now</button><button class="s" onclick="icClose()">Close</button></div>
|
|
<div class="row"><label>Width <input type="number" id="ic_w" min="1" max="84" style="width:4.5em"></label>
|
|
<label>Height <input type="number" id="ic_h" min="1" max="19" style="width:4.5em"></label>
|
|
<button class="s" onclick="icCrop()">Crop to the dots</button><button class="s" onclick="icClear()">Clear</button><button class="s" onclick="icInvert()">Invert</button>
|
|
<label><input type="file" id="ic_file" accept="image/*"> Image</label></div>
|
|
<div class="row" id="ic_imgopts" style="display:none"><label>Threshold <input type="range" id="ic_thr" min="1" max="254" value="128"></label>
|
|
<label><input type="checkbox" id="ic_inv"> Invert</label><label><input type="checkbox" id="ic_dith"> Dither</label></div>
|
|
<canvas id="ic_canvas" width="840" height="190"></canvas>
|
|
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div>
|
|
<div class="card"><h2>Game of Life patterns</h2><div class="grid" id="lifepats"></div>
|
|
<div class="row"><button class="b" onclick="lifeAdd(LIFEPATS)">Add all</button></div>
|
|
<p class="hint">Famous patterns from LifeWiki (conwaylife.com), added as icons named life_... A Game of Life element on a page can start with one of them, or go through all icons whose names start with life_ (also the ones you draw yourself).</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>
|
|
<button class="s" onclick="saveAsIcon()">Save as icon</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). Save as icon keeps only the area with dots.</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>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
|
|
</div></div>
|
|
<div class="card"><h2>Slow mode</h2><div class="form">
|
|
<label>Slow mode</label><label><input type="checkbox" id="s_slow"> on (also the Home Assistant switch)</label>
|
|
<label>Update every</label><select id="s_slowmin"><option value="1">minute</option><option value="2">2 minutes</option><option value="5">5 minutes</option><option value="10">10 minutes</option><option value="15">15 minutes</option><option value="20">20 minutes</option><option value="30">30 minutes</option><option value="60">hour</option></select>
|
|
</div><p class="hint">Fewer flips, less noise: the display only changes at these times (on the clock, 15 minutes = :00 :15 :30 :45) and is switched off in between; the dots keep the image. Page changes wait too, except for rules with "show at once in slow mode".</p></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"> µs (50-2000)</label>
|
|
<label>Flip gap</label><label><input type="number" id="h_gap" min="0" max="5000" style="width:6em"> µs pause after each pulse (0-5000)</label>
|
|
<label>Pulses</label><label><select id="h_pulses"><option>1</option><option>2</option><option>3</option></select> per changed dot</label>
|
|
<label>Refresh effect</label><select id="h_effect"><option value="0">Instant (left to right)</option><option value="1">Sparkle</option>
|
|
<option value="2">Wipe (left to right)</option><option value="3">Rain (top to bottom)</option><option value="4">From the middle out</option>
|
|
<option value="5">Diagonal</option><option value="6">Spiral (outside in)</option><option value="7">Flip all, then reveal</option>
|
|
<option value="8">Push (slides in from the right)</option><option value="9">Random</option></select>
|
|
<label>Effect for</label><label><input type="checkbox" id="h_small"> small changes only (the clock's minutes); a new page appears at once</label>
|
|
<label>Effect time</label><label><input type="number" id="h_efft" min="100" max="10000" style="width:6em"> ms (100-10000, not for Instant; Push needs more when many dots change)</label>
|
|
<label>Dot refresh</label><label>every <input type="number" id="h_rdots" min="0" max="1440" style="width:5em"> min (0 = off): every dot flipped again</label>
|
|
<label>Image resync</label><label>every <input type="number" id="h_rimg" min="0" max="1440" style="width:5em"> min (0 = off): the whole image sent again</label>
|
|
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
|
|
</div><p class="hint">Dots that don't flip: a longer gap gives the power supply time to recover between pulses, a second pulse catches dots that missed the first one. The dot refresh (and the first frame after power on) uses one pulse per dot; it runs between frames. The image resync repairs a picture the Nano got wrong (e.g. shifted); only dots that differ flip, so normally nothing changes. A wrong picture is also found and repaired by the frame check (see System).</p></div>
|
|
<div class="card"><h2>Test patterns</h2>
|
|
<div class="row"><button class="s" onclick="pattern('yellow')">All yellow</button><button class="s" onclick="pattern('black')">All black</button>
|
|
<button class="s" onclick="pattern('checker')">Checkerboard</button><button class="s" onclick="pattern('checker2')">Checkerboard inverted</button>
|
|
<button class="s" onclick="pattern('rows')">Rows</button><button class="s" onclick="pattern('cols')">Columns</button><button class="s" onclick="pattern('border')">Border</button></div>
|
|
<div class="row"><button class="s" onclick="cycleTest()">Cycle checkerboards</button><button class="s" onclick="stopTest()">Back to normal</button></div>
|
|
<p class="hint">Shown in live mode, with the current flip settings (save them first). Cycle flips every dot back and forth every few seconds: look for dots that stay behind.</p></div>
|
|
<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">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://<broker>:8123">
|
|
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
|
|
<label>Plex user</label><input id="ha_plex" placeholder="e.g. mangavalk" autocomplete="off">
|
|
</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. Plex user: entity ids in pages and rules can contain {plex_user}, which is replaced by this name the way Home Assistant writes it (lower case, _ for spaces), e.g. sensor.{plex_user}_state for Tautulli.</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>
|
|
<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>
|
|
<div class="card"><h2>Backup</h2>
|
|
<div class="row"><button class="b" onclick="exportAll()">Export</button>
|
|
<label><input type="checkbox" id="x_secrets"> include the MQTT password and Home Assistant token</label></div>
|
|
<h3>Import</h3><div class="row"><label><input type="file" id="i_file" accept=".json,application/json"> Backup file</label></div>
|
|
<div id="i_box" style="display:none"><div id="i_list"></div>
|
|
<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, the schedule and rules, and your bitmaps, icons, dynamic pages and fonts (not the built-in ones). Single pages, bitmaps and icons have their own Export button (a page includes the bitmaps, icons 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=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
|
|
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['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)}
|
|
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,w=W,h=H){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}
|
|
// icons: Uint8Array(w*h) <-> hex, rows of (w+7)>>3 bytes (src/esp32/logic/icon_format.h)
|
|
function icFromHex(w,h,hx){const rb=(w+7)>>3,b=new Uint8Array(w*h);for(let y=0;y<h;y++)for(let x=0;x<w;x++)b[y*w+x]=(parseInt(hx.substr((y*rb+(x>>3))*2,2),16)>>(7-(x&7)))&1;return b}
|
|
function icToHex(b,w,h){let hx='';const rb=(w+7)>>3;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}hx+=v.toString(16).padStart(2,'0')}return hx}
|
|
// the icon at the top left of a full screen bitmap (how a background shows it)
|
|
function icFull(b,w,h){const f=new Uint8Array(W*H);for(let y=0;y<h;y++)for(let x=0;x<w;x++)f[y*W+x]=b[y*w+x];return f}
|
|
// the part of an image with dots: {bits,w,h,x,y}; a blank dot when there are none
|
|
function cropBits(b,w,h){let x0=w,y0=h,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++)if(b[y*w+x]){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y)}
|
|
if(x1<0)return{bits:new Uint8Array(1),w:1,h:1,x:0,y:0};const cw=x1-x0+1,ch=y1-y0+1,o=new Uint8Array(cw*ch);
|
|
for(let y=0;y<ch;y++)for(let x=0;x<cw;x++)o[y*cw+x]=b[(y+y0)*w+x+x0];return{bits:o,w:cw,h:ch,x:x0,y:y0}}
|
|
function icCanvas(b,w,h){const s=Math.max(2,Math.min(16,Math.floor(336/w),Math.floor(76/h))),c=mk('canvas',{width:w*s,height:h*s});draw(c,b,w,h);return mk('div',{className:'icbox'},c)}
|
|
// 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';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'],
|
|
['Loop',status.load?(status.load.loops_per_s?status.load.loops_per_s+'/s, avg '+(status.load.avg_us/1000).toFixed(1)+' ms, max '+status.load.max_ms+' ms (since boot '+status.load.max_ever_ms+' ms)':'measuring...'):''],
|
|
['Frame check',status.nano_link?(()=>{const n=status.nano_link;return n.checks+' ok, '+n.mismatches+' wrong, '+n.no_reply+' no answer, '+n.repairs+' repaired'+(n.restarts?', Nano restarted '+n.restarts+'x':'')+(n.errors?', '+n.errors+' errors (last: '+n.last_error+')':'')})():''],
|
|
['Memory',status.load?Math.round(status.load.heap_free/1024)+' KB free (lowest '+Math.round(status.load.heap_min/1024)+' KB)':''],
|
|
['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, 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]:[]}}
|
|
// 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}
|
|
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
|
|
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
|
|
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
|
|
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
|
|
if(e.type!='image')return;if(!e.entity)add(e.text);
|
|
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});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,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
|
|
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data));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'),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)));
|
|
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('Export',()=>exportItem('page',pg.name)),pg.original?btn('Reset',()=>resetPage(pg.name),'d'):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';
|
|
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('Make icon',()=>toIcon(bm.name)),btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
|
|
const ics=$('icons'),users=pageList.map(p=>[p.name,pageImages(p.page)]);ics.innerHTML='';
|
|
for(const x of iconList){const by=users.filter(([,s])=>s.has(x.name.toLowerCase())).map(([n])=>n);
|
|
const d=mk('div',{className:'item'},icCanvas(x.bits,x.width,x.height),mk('div',{className:'name',textContent:x.name}),
|
|
mk('div',{className:'tag',textContent:x.width+' x '+x.height+', '+(by.length?'on '+by.join(', '):'not used'),title:by.join(', ')}),
|
|
mk('div',{className:'row'},btn('Edit',()=>icOpen(x)),btn('Make bitmap',()=>toBitmap(x.name)),btn('Export',()=>exportItem('icon',x.name)),btn('Delete',()=>delIcon(x.name),'d')));
|
|
ics.appendChild(d)}
|
|
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));lifeLib();
|
|
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()}
|
|
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=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 delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
|
|
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
|
|
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
|
|
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();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()}
|
|
function swText(ms){const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(d?d+'d '+h+':'+p(m):h?h+':'+p(m):m)+':'+p(s%60)}
|
|
function swShow(sw){if(!sw)return;$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'');$('swsince').textContent=sw.start?'Counting since '+sw.start.replace('T',' '):sw.ms?'Paused':''}
|
|
async function stopwatch(action,extra){swShow(await post('/api/stopwatch',Object.assign({action},extra)));if(pe)pePreview()}
|
|
async function swSetStart(){const v=$('swstart').value;if(!v){msg('Pick a date and time first',true);return}await stopwatch('set',{start:v.slice(0,16)});msg('Counting since '+v.replace('T',' '))}
|
|
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 schedLink(){sched.rules.forEach(r=>{if(!r._b)r._b=(r.blocks||[]).map(t=>sched.blocks.find(b=>b.start==t)).filter(Boolean)})}
|
|
function schedPayload(){return{enabled:sched.enabled,blocks:sched.blocks,rules:sched.rules.map(r=>{const o=Object.assign({},r);
|
|
o.blocks=sched.blocks.filter(b=>(r._b||[]).includes(b)).map(b=>b.start);delete o._b;return o})}}
|
|
function skipSlow(o){const c=mk('input',{type:'checkbox',checked:!!o.skip_slow_mode});c.onchange=()=>{o.skip_slow_mode=c.checked;schedDirty=true};
|
|
return mk('label',{title:'Left out of the rotation while slow mode is on'},c,' skip in slow mode')}
|
|
function schedBuild(){if(!sched)return;schedLink();$('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',
|
|
skipSlow(it),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,skip_slow_mode:false});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':'';
|
|
const skip=skipSlow(ru);skip.style.display=ru.mode=='rotate'?'':'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()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
|
|
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':'';skip.style.display=ru.mode=='rotate'?'':'none'}),secs,skip,
|
|
'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')),
|
|
mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:ru.over_manual!==false});c.onchange=()=>{ru.over_manual=c.checked;schedDirty=true};
|
|
return mk('label',{},c,' also over a page chosen by hand')})(),mk('span',{className:'hint',textContent:'(off: it waits until the chosen page ends: the next block or Back to schedule)'})),
|
|
mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:!!ru.over_slow_mode});c.onchange=()=>{ru.over_slow_mode=c.checked;schedDirty=true};
|
|
return mk('label',{},c,' show at once in slow mode')})(),mk('span',{className:'hint',textContent:'(the display works normally while this page is shown)'})),
|
|
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
|
|
c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}),
|
|
mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))});
|
|
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,over_manual:true,over_slow_mode:false,skip_slow_mode:false});schedDirty=true;schedBuild()}
|
|
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());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',bar:'Progress bar',life:'Game of Life'};
|
|
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
|
|
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
|
|
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 tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
|
|
function newEl(t){const e={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:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:'',
|
|
bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0};
|
|
// Game of Life (src/esp32/logic/page.h): format = edges, factor = density, scroll_speed = generations/s, bar_max = hold s
|
|
if(t=='life')Object.assign(e,{bar_width:84,bar_height:19,format:2,start:'random',factor:30,scroll_speed:4,auto_unit:true,generations:0,bar_max:5,text:'life_'});
|
|
return e}
|
|
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';$('t-pages').scrollIntoView({behavior:'smooth'})}
|
|
function peBuild(){peSlots();peElems();peEffect()}
|
|
function peEffect(){const s=$('pe_effect');if(!s.options.length){s.append(mk('option',{value:-1,textContent:'Default (Hardware setting)'}));
|
|
for(const o of $('h_effect').options)s.append(mk('option',{value:o.value,textContent:o.textContent}))}
|
|
if(pe.page.effect==null)pe.page.effect=-1;s.value=pe.page.effect}
|
|
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 chk(o,k,label){const c=mk('input',{type:'checkbox',checked:!!o[k]});c.onchange=()=>{o[k]=c.checked;pePreview()};return mk('label',{},c,' '+label)}
|
|
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+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(iconList.map(x=>[x.name,x.name+' (icon)']),bitmapList.map(b=>[b.name,b.name+' (bitmap)']));
|
|
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
|
|
const o=s.bitmap&&!opts.some(x=>same(x[0],s.bitmap))?opts.concat([[s.bitmap,s.bitmap+' (gone)']]):opts;
|
|
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',o),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='';d.append(mk('datalist',{id:'bmnames'},...iconList.concat(bitmapList).map(b=>mk('option',{value:b.name}))));
|
|
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'))];
|
|
const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
|
|
if(e.type=='clock')row(field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05'],[2,'08:05:09 (every second)'],[3,'8:05:09 (every second)']])));
|
|
if(e.type=='date')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)),
|
|
field('Day',sel(e,'day_offset',[[0,'today'],[1,'tomorrow'],[2,'+2 days'],[3,'+3 days'],[4,'+4 days'],[5,'+5 days'],[6,'+6 days'],[7,'+7 days']])));
|
|
if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
|
|
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state) or forecast.1.temperature',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
|
const sh=e.show||'number',fmt=()=>{e.format=0;peElems()};
|
|
row(field('Show as',sel(e,'show',[['number','number'],['duration','duration'],['time','time / date'],['map','own texts']],fmt)),
|
|
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
|
|
if(sh=='number')row(field('Multiply by',inpf(e,'factor',{style:'width:6em'})),field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']])),
|
|
chk(e,'auto_unit','auto unit (the text after is the unit: 1257 W shows as 1.3 kW)'),chk(e,'absolute','without minus sign'));
|
|
if(sh=='duration')row(field('The value is in',sel(e,'input_unit',[['s','seconds'],['min','minutes'],['h','hours']])),
|
|
field('Format',sel(e,'format',[[0,'2h 21m'],[1,'2:21:00'],[2,'2:21'],[3,e.language=='en'?'smart: 2 hours':'smart: 2 uur']])),
|
|
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
|
|
if(sh=='time')row(field('Format',sel(e,'format',[[0,'23:07'],[1,e.language=='en'?'Thu 23:07':'do 23:07'],[2,'15-10 23:07'],[3,e.language=='en'?'in 34 min / 12 min ago':'over 34 min / 12 min geleden']])),
|
|
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)),mk('span',{className:'hint',textContent:'for timestamps like 2026-10-15T21:07:00+00:00'}));
|
|
if(sh=='map')row(field('Texts',inp(e,'map','text',{size:40,maxLength:63,placeholder:'printing=Bezig, standby=Klaar, paused=Pauze'})),
|
|
mk('span',{className:'hint',textContent:'state=text, ... (other states as they are)'}))}
|
|
if(e.type=='text')row(field('Text',inp(e,'text','text',{size:24,maxLength:32})));
|
|
if(e.type=='countdown'){row(field('Until',inp(e,'target','datetime-local')),field('Format',sel(e,'format',CDF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
|
|
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
|
|
row(field('Before',inp(e,'text','text',{size:10,maxLength:32})),field('When passed',inp(e,'suffix','text',{size:8,maxLength:16,placeholder:'0'})))}
|
|
if(e.type=='stopwatch'){row(field('Format',sel(e,'format',[[0,'4:05 (every second)'],[1,'0:04 (hours, minutes)'],[2,'0:04:05 (every second)'],[3,'12d 4:05:09 (days, every second)'],[4,'3w 2d (weeks, days)']])),
|
|
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
|
|
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
|
|
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
|
|
row(field(e.entity?'Icon name start':'Icon',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
|
|
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
|
|
rows.push(mk('p',{className:'hint',textContent:'An icon (Icons tab) or a bitmap. With an entity the icon is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
|
|
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
|
|
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
|
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
|
|
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})),
|
|
chk(e,'bar_frame','frame (from 3 x 3 dots)'));
|
|
const pc=mk('input',{type:'checkbox',checked:e.format==1});pc.onchange=()=>{e.format=pc.checked?1:0;if(pc.checked&&same(e.font,'Normal'))e.font='Small';peElems();pePreview()};
|
|
row(mk('label',{},pc,' percentage on top (inverts the bar below it; only shown when it fits inside, Small needs a height of 7 with frame)'));
|
|
rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))}
|
|
if(e.type=='life'){const rot=e.start=='rotate'||e.start=='shuffle',pat=inp(e,'text','text',{size:rot?10:20,maxLength:rot?23:24,placeholder:rot?'life_':'life_glider'});
|
|
if(!rot)pat.setAttribute('list','bmnames');const n=iconList.filter(x=>x.name.toLowerCase().startsWith((e.text||'').toLowerCase())).length;
|
|
row(field('Start',sel(e,'start',[['random','random dots'],['pattern','one pattern'],['rotate','patterns in turn'],['shuffle','patterns in random order']],peElems)),
|
|
...(e.start=='random'?[field('Density',inp(e,'factor','number',{style:'width:4em',min:1,max:100})),'%']:[]),
|
|
...(e.start=='pattern'?[field('Pattern',pat)]:[]),...(rot?[field('Icons whose names start with',pat),mk('span',{className:'hint',textContent:'('+n+' now)'})]:[]),
|
|
...(e.start!='random'?[chk(e,'absolute','at a random place (otherwise in the middle)')]:[]));
|
|
row(field('Edges',sel(e,'format',[[0,'wrap around'],[1,'dead (nothing outside)'],[2,'hidden margin (gliders fly away)']])),
|
|
field('Speed',inpf(e,'scroll_speed',{style:'width:5em',min:0.1,max:20})),'generations per second',
|
|
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})));
|
|
row(chk(e,'auto_unit','restart when it stops changing (still or repeating)'),field('or after',inp(e,'generations','number',{style:'width:6em',min:0})),'generations (0: never);',
|
|
field('first keep running',inp(e,'bar_max','number',{style:'width:4em',min:0,max:3600})),'s');
|
|
rows.push(mk('p',{className:'hint',textContent:'Patterns are icons: add famous ones in the Icons tab (Game of Life patterns) or draw your own. Wrap around: what leaves one side comes back on the other. Hidden margin: the world is larger than the area, so gliders fly out of view. A new generation is sent once the dots of the last one have flipped, so a busy board can be slower than the speed. The preview shows the running board while this page is shown, otherwise its start.'}))}
|
|
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)']);
|
|
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
|
|
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
|
|
if(TEXTTYPES.includes(e.type)){
|
|
row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl);
|
|
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
|
|
inpf(e,'scroll_speed',{style:'width:5em',min:0.05,max:30}),'dots per second (0.25: one every 4 s)')}
|
|
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),...(e.format==1?[field('Font',sel(e,'font',fonts))]:[]),ovl);
|
|
else if(e.type=='life')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
|
|
else row(...pos,ovl);
|
|
box.append(...rows);d.append(box)})}
|
|
function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}
|
|
if(t=='life'&&pe.page.elements.some(e=>e.type=='life')){msg('One Game of Life per page',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()});
|
|
// The ESP32 reads the value in the background: ask again until it is there
|
|
async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api('/api/ha/value?entity='+encodeURIComponent(entity)+'&attribute='+encodeURIComponent(attribute));
|
|
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
|
|
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);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<128;k++){const ch=k==127?'\u00b0':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,96].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=='\u00b0'?95:ch.charCodeAt(0)-32;if(k<0||k>95)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()}
|
|
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
|
|
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
|
|
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
|
|
['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,refresh:s.refresh,status_led:s.status_led})],
|
|
['mqtt','Home Assistant MQTT (restarts the ESP32)',s=>{const h=s.home_assistant||{},o={enabled:h.enabled,host:h.host,port:h.port,user:h.user};if(h.password!==undefined)o.password=h.password;return{home_assistant:o}}],
|
|
['haapi','Home Assistant values (URL, token, Plex user)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o,plex_user:s.plex_user}}]];
|
|
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
|
|
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
|
|
document.body.append(a);a.click();a.remove()}
|
|
function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],icons:[],pages:[],fonts:[]}}
|
|
const icItem=x=>({name:x.name,width:x.width,height:x.height,data:x.data});
|
|
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,ic]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts'),api('/api/icons')]);
|
|
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.icons=ic.icons.map(icItem);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.icons.length+' icons, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
|
|
// one bitmap or icon, or one page with the bitmaps, icons and fonts it uses (not the built-in ones), without settings
|
|
async function exportItem(kind,name){const out=backup();
|
|
if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})}
|
|
else if(kind=='icon')out.icons.push(icItem(iconList.find(x=>same(x.name,name))));
|
|
else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page});const used=pageImages(p.page);
|
|
for(const b of bitmapList)if(!b.builtin&&used.has(b.name.toLowerCase()))out.bitmaps.push({name:b.name,data:b.data});
|
|
for(const x of iconList)if(used.has(x.name.toLowerCase()))out.icons.push(icItem(x));
|
|
for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}}
|
|
download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)}
|
|
// import: settings only when ticked; items are added under a free name unless you give them an existing one
|
|
let imp=null,impRows=[];
|
|
$('i_file').onchange=async e=>{const f=e.target.files[0];if(!f)return;e.target.value='';
|
|
try{imp=JSON.parse(await f.text());if(!imp||imp.format!='flipdot-backup')throw 0}catch(err){imp=null;$('i_box').style.display='none';msg('Not a FlipDot backup file',true);return}
|
|
$('i_result').textContent='';impShow()};
|
|
function freeName(n,taken){if(!taken(n))return n;for(let i=2;;i++){const s=' '+i,c=n.slice(0,24-s.length).trim()+s;if(!taken(c))return c}}
|
|
function impShow(){const d=$('i_list');d.innerHTML='';impRows=[];const ha=(imp.settings&&imp.settings.home_assistant)||{};
|
|
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, icons and pages share their names, fonts have their own
|
|
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList,iconList).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));
|
|
const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title}));
|
|
list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name);
|
|
const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})};
|
|
row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})};
|
|
group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Icons','i',imp.icons||[]);group('Dynamic pages','p',imp.pages||[]);
|
|
$('i_box').style.display='block'}
|
|
function impNote(r){const n=r.i.value.trim(),lists={f:fontList,b:bitmapList,i:iconList,p:pageList},words={b:'bitmap',i:'icon',p:'page'};
|
|
const replaces=lists[r.kind].some(x=>same(x.name,n)),clash=r.kind=='f'?'':['b','i','p'].find(k=>k!=r.kind&&lists[k].some(x=>same(x.name,n)));
|
|
r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+words[clash]:'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
|
|
r.note.style.color=!n||replaces||clash?'var(--bad)':''}
|
|
function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)}
|
|
// fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last
|
|
async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key));
|
|
const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return}
|
|
const errors=[],rename={f:{},b:{},i:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...';
|
|
const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}};
|
|
for(const kind of['f','b','i','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim();
|
|
if(!n){errors.push(r.item.name+': no name');continue}
|
|
let ok;
|
|
if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data});
|
|
else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data});
|
|
else if(kind=='i')ok=await run('Icon '+n,'/api/icon',{name:n,width:r.item.width,height:r.item.height,data:r.item.data});
|
|
else{const page=JSON.parse(JSON.stringify(r.item.page)),img=x=>to('i',x)||to('b',x)||x;(page.slots||[]).forEach(s=>s.bitmap=img(s.bitmap));
|
|
(page.elements||[]).forEach(e=>{e.font=to('f',e.font)||e.font;if(e.type=='image'&&!e.entity)e.text=img(e.text)});ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
|
|
if(ok)rename[kind][r.item.name.toLowerCase()]=n}
|
|
await loadLists();
|
|
if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings));
|
|
if(body.page)body.page=to('p',body.page)||to('b',body.page)||body.page;if(body.off_page)body.off_page=to('b',body.off_page)||body.off_page;
|
|
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)}
|
|
// 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);loadLists()}
|
|
async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
|
|
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists()}
|
|
// image conversion: w x h dots, dark parts on
|
|
$('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 imgBits(img,w,h,thr,inv,dith,fit){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 iw=img.width,ih=img.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(img,(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 b=new Uint8Array(w*h);
|
|
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}}}
|
|
return b}
|
|
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
|
|
// Game of Life patterns from LifeWiki (checked with a simulation): icon name, what it does, RLE
|
|
const LIFEPATS=[['life_glider','flies diagonally','bo$2bo$3o!'],['life_lwss','lightweight spaceship','bo2bo$o4b$o3bo$4o!'],
|
|
['life_mwss','middleweight spaceship','3bo2b$bo3bo$o5b$o4bo$5o!'],['life_hwss','heavyweight spaceship','3b2o2b$bo4bo$o6b$o5bo$6o!'],
|
|
['life_copperhead','slow spaceship (c/10)','3b2obo$o6bo$o2bo3b2o$b2o5bob2o$b2o5bob2o$o2bo3b2o$o6bo$3b2obo!'],
|
|
['life_gosper_gun','glider gun (period 30)','24bo$22bobo$12b2o6b2o12b2o$11bo3bo4b2o12b2o$2o8bo5bo3b2o$2o8bo3bob2o4bobo$10bo5bo7bo$11bo3bo$12b2o!'],
|
|
['life_pulsar','oscillator (period 3)','2b3o3b3o2$o4bobo4bo$o4bobo4bo$o4bobo4bo$2b3o3b3o2$2b3o3b3o$o4bobo4bo$o4bobo4bo$o4bobo4bo2$2b3o3b3o!'],
|
|
['life_pentadecathlon','oscillator (period 15)','2bo4bo$2ob4ob2o$2bo4bo!'],['life_queen_bee','shuttle (period 30)','9bo$7bobo$6bobo$2o3bo2bo11b2o$2o4bobo11b2o$7bobo$9bo!'],
|
|
['life_figure_eight','oscillator (period 8)','3o$3o$3o$3b3o$3b3o$3b3o!'],['life_kok_galaxy','oscillator (period 8)','6ob2o$6ob2o$7b2o$2o5b2o$2o5b2o$2o5b2o$2o$2ob6o$2ob6o!'],
|
|
['life_beacon','oscillator (period 2)','2o$o$3bo$2b2o!'],['life_r_pentomino','chaos for 1103 generations','b2o$2o$bo!'],
|
|
['life_acorn','chaos for 5206 generations','bo$3bo$2o2b3o!'],['life_diehard','vanishes after 130 generations','6bo$2o$bo3b3o!'],
|
|
['life_thunderbird','settles after 243 generations','3o2$bo$bo$bo!'],['life_pi_heptomino','settles after 173 generations','3o$obo$obo!']];
|
|
function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>='0'&&c<='9'){n+=c;continue}const k=+n||1;n='';
|
|
if(c=='b')x+=k;else if(c=='o'){for(let i=0;i<k;i++)p.push([x+i,y]);x+=k;w=Math.max(w,x)}else if(c=='$'){y+=k;x=0}else if(c=='!')break}
|
|
const h=y+1,b=new Uint8Array(w*h);p.forEach(([a,c])=>b[c*w+a]=1);return{w,h,bits:b}}
|
|
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
|
|
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
|
|
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
|
|
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
|
|
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}}
|
|
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
|
|
// icon editor: its own size, drawn like the bitmap editor
|
|
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
|
|
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
|
|
function icOpen(x){ic={orig:x.name,w:x.width,h:x.height,bits:x.bits.slice()};icShow('Edit '+x.name)}
|
|
function icNew(){ic={orig:'',w:11,h:11,bits:new Uint8Array(121)};icShow('New icon')}
|
|
function icClose(){ic=null;$('ic').style.display='none'}
|
|
function icDraw(){const s=Math.max(4,Math.min(40,Math.floor(840/ic.w),Math.floor(320/ic.h)));icc.width=ic.w*s;icc.height=ic.h*s;draw(icc,ic.bits,ic.w,ic.h)}
|
|
function icResize(w,h,bits,bw,bh){const b=new Uint8Array(w*h);for(let y=0;y<Math.min(h,bh);y++)for(let x=0;x<Math.min(w,bw);x++)b[y*w+x]=bits[y*bw+x];return b}
|
|
function icSize(){const w=Math.min(84,Math.max(1,Math.round(+$('ic_w').value||1))),h=Math.min(19,Math.max(1,Math.round(+$('ic_h').value||1)));
|
|
if(w==ic.w&&h==ic.h)return;ic.bits=icResize(w,h,ic.bits,ic.w,ic.h);ic.w=w;ic.h=h;if(icImg)icConvert();icDraw()}
|
|
$('ic_w').onchange=icSize;$('ic_h').onchange=icSize;
|
|
function icCrop(){const c=cropBits(ic.bits,ic.w,ic.h);Object.assign(ic,{bits:c.bits,w:c.w,h:c.h});$('ic_w').value=c.w;$('ic_h').value=c.h;icDraw()}
|
|
function icClear(){ic.bits=new Uint8Array(ic.w*ic.h);icDraw()}
|
|
function icInvert(){ic.bits=ic.bits.map(v=>1-v);icDraw()}
|
|
function icPos(e){const r=icc.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*ic.w),Math.floor((e.clientY-r.top)/r.height*ic.h)]}
|
|
function icDot(e){const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h||ic.bits[y*ic.w+x]==icPaint)return;ic.bits[y*ic.w+x]=icPaint;icDraw()}
|
|
icc.onpointerdown=e=>{if(!ic)return;const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h)return;icPaint=ic.bits[y*ic.w+x]?0:1;icc.setPointerCapture(e.pointerId);icDot(e)};
|
|
icc.onpointermove=e=>{if(icPaint!==null)icDot(e)};icc.onpointerup=()=>{icPaint=null};
|
|
$('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=new Image();img.onload=()=>{icImg=img;$('ic_imgopts').style.display='flex';icConvert()};img.src=URL.createObjectURL(f)};
|
|
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert());
|
|
function icConvert(){if(!icImg||!ic)return;ic.bits=imgBits(icImg,ic.w,ic.h,+$('ic_thr').value,$('ic_inv').checked,$('ic_dith').checked,'contain');icDraw()}
|
|
async function icSave(){const n=$('ic_name').value.trim();if(!n){msg('Enter a name',true);return}
|
|
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
|
|
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
|
|
// test patterns (live mode)
|
|
let testTimer=null;
|
|
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
|
|
p=='yellow'?1:p=='black'?0:p=='checker'?(x+y)%2:p=='checker2'?(x+y+1)%2:p=='rows'?y%2:p=='cols'?x%2:(x==0||y==0||x==W-1||y==H-1)?1:0;return b}
|
|
async function pattern(p){clearInterval(testTimer);testTimer=null;await live(patternBits(p))}
|
|
async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(patternBits(n++%2?'checker2':'checker')).catch(()=>{clearInterval(testTimer);testTimer=null});
|
|
await step();testTimer=setInterval(step,Math.max(4000,(settings.drive.effect?settings.drive.effect_time:0)+2000))}
|
|
async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()}
|
|
// settings
|
|
function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;
|
|
$('s_night').value=s.schedule.night_start;$('s_slow').checked=s.slow_mode.enabled;$('s_slowmin').value=s.slow_mode.minutes;
|
|
$('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;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image;
|
|
$('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_url').value=s.home_assistant.api_url;$('ha_plex').value=s.plex_user||'';
|
|
$('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,night_start:$('s_night').value.trim()});
|
|
const sm={enabled:$('s_slow').checked,minutes:+$('s_slowmin').value};
|
|
if(sm.enabled!=settings.slow_mode.enabled||sm.minutes!=settings.slow_mode.minutes)s.slow_mode=sm;else delete s.slow_mode; // HA may have switched it meanwhile
|
|
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};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value};
|
|
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()});s.plex_user=$('ha_plex').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()}
|
|
// #tab, or #pages/<name> to open a page in the editor
|
|
async function start(){settings=await api('/api/settings');fill();const[t,arg]=decodeURIComponent((location.hash||'#show').slice(1)).split('/');
|
|
tab(t);pollScreen();pollStatus();await loadLists();
|
|
if(t=='pages'&&arg){const pg=pageList.find(p=>same(p.name,arg));if(pg)peOpen(pg,pg.builtin)}}
|
|
start();
|
|
</script></body></html>)rawliteral";
|