The clock serves its own control panel. Open http://esptube.local (or the IP, e.g.
http://<clock-ip>) from any browser on the same network — phone or laptop. No app, no
hosting; the page is embedded in the firmware and talks to the on-board REST API (same-origin).
Source of truth: firmware/custom-fw/data/index.html
(self-contained HTML/CSS/JS). A PlatformIO pre-build step (scripts/gen_web_index.py) embeds it
into the app as PROGMEM (src/web_index.h) on every build, so editing that one file + reflashing
updates the UI — no separate filesystem upload.
Header — title + a status pill that polls GET /status every 5 s (green dot + IP + free heap
when online; “offline” otherwise).
Top bar
| Control | Action | Endpoint |
|—|—|—|
| Clear all | blank every tube | POST /tubes/clear |
| Refresh | re-read status now | GET /status |
| MODE ◀ ▶ ⏻ | virtual hardware buttons (same actions as the physical ones) | POST /button/{mode\|left\|right\|power} |
The four buttons mirror — and now match — the clock’s physical UP/MODE/DOWN/POWER (GPIO 35/34/39/36, active-low, external pull-ups). Actions: MODE cycles Clock↔Off, POWER toggles Off↔last mode, UP/DOWN (◀/▶) step brightness ±32. Physical and web buttons run the identical handler. (Add more canned modes and MODE-cycle gains more stops.)
| Control | Action | Endpoint |
|—|—|—|
| Mode: Clock / Off | switch display mode (CLOCK = NTP time; OFF = blank; MANUAL auto-set when you push content) | POST /mode/{clock\|off\|manual} |
| Brightness slider | scale the back LEDs (0–255, persisted) | POST /config/brightness {"value":N} |
| Timezone field | POSIX TZ for the clock (persisted) | POST /config/tz {"tz":"…"} |
CLOCK renders NTP time across the populated tubes (6→HHMMSS, 5→HH MM + seconds, 4→HHMM),
right-aligned, redrawing only changed digits. GET /status reports mode, time_valid, tz,
brightness. Default TZ PST8PDT,M3.2.0,M11.1.0 (America/Los_Angeles).
Per-tube cards (one per slot, shown in physical order left→right = index 5…0; dead/unpopulated
slots are dimmed)
| Control | Action | Endpoint |
|—|—|—|
| text field + Draw | draw centered text (≤8 chars) | POST /tube/{i}/text (raw body) |
| color + Glow | set that tube’s back WS2812 LED | POST /tube/{i}/rgb ({r,g,b}) |
| Clear | blank that tube (text + LED off) | text "" + rgb 0,0,0 |
| Image (file) | pick any image → resized to 135×240 and BMP-encoded in the browser → sent | POST /tube/{i}/image (BMP bytes) |
Back LEDs — all tubes — one color picker → Set all / All off; loops the populated tubes
via POST /tube/{i}/rgb. (Per-tube “Glow” sets one; this sets them all.)
Which tubes are populated — a checkbox per slot + Save layout. Since these panels can’t be
auto-detected, you declare which slots have a working display; saved on the device.
POST /config/populated {"mask":N} (bit i = slot i). Reflected in GET /config and /status
(populated_mask, per-tube populated, dead_tubes). Checkboxes load from the device once, then
don’t get clobbered by the background poll while you edit.
Access-Control-Allow-Origin: * is also set for external tools.)<img> → 135×240 canvas (cover-fit) → 24-bit BMP encoded
in JS → posted. So you can drop in a PNG/JPG of any size; the browser does the conversion.