SOLOreference

Technical reference

How the device, the hub and the console actually work — contracts, geometry, schema and the traps that cost time. If you only want to get a number onto a screen, read the guide instead; this page assumes you are changing the thing.

Two documents, one truth. SOLO-STATE.md in the repo is the operational log and the authority when anything here disagrees with it. Above that sits one rule: the firmware wins. The hub and console were regenerated after a lost session; the sketch is the genuine original, so where a contract is disputed, read solo-firmware/solo-firmware.ino.

01The shape of the system

One number, one screen. A metric is pushed (or pulled) into a slot on the hub; a device is a round panel bound to one slot, and it asks the hub every few seconds what to draw. Nothing is configured on the device itself, which is why swapping a board is just a re-claim.

Sources push: Actions · n8n pull: GitHub · GA4 · Mixpanel Hub — Worker solo-hub · rev Q D1: slots · devices history · pull_log · meta ESP32 240×240 Console — Pages this website POST /ingest cron pulls (rev L) GET /d/{mac} every p seconds /admin/* claim code, by eye
Everything except the panel runs in Cloudflare. The device never talks to a source and never learns a secret; it knows one URL and its own MAC.
The one-way rule. The device only ever reads /d/{mac}, unauthenticated. It cannot write, cannot be addressed, and holds no key. Everything that mutates state goes through /admin/* with the admin key, or /ingest/* with the ingest key.

02The device

Hardware

A classic ESP32 driving a GC9A01 240×240 round SPI panel over Arduino_GFX. It is the same physical board and panel as the first Desk Radar build, so the pin table below is empirical, not inferred — a wrong SCK/MOSI/DC/CS/RST presents as a black or garbled screen.

SignalGPIONote
SCK18VSPI default
MOSI23does not exist on an ESP32-C3 — proof this is a classic ESP32
DC2
CS15
RST4
BL-1backlight left unconnected on this build

Firmware build

Libraries: Arduino_GFX, WiFiManager (tzapu), ArduinoJson, ArduinoOTA, Preferences.

arduino-cli compile --fqbn esp32:esp32:esp32:PartitionScheme=min_spiffs solo-firmware

Current image: 1,256,447 bytes — 63% of the 1,966,080-byte app partition; globals 55,192 (16%).

min_spiffs is load-bearing. The default scheme gives a ~1.2 MB app slot and the sketch does not fit — you get "text section exceeds available space in board". Do not "fix" that with huge_app: it drops the second app partition and kills OTA, which is this firmware's entire update story after the first wired flash.

Lifecycle

  1. Boot. Paints SOLO / hello / starting up, reads the persisted hub URL from Preferences (namespace solo, key hub).
  2. WiFi. Paints wifi setup / SOLO-setup once, then blocks in WiFiManager's autoConnect("SOLO-setup"). The captive portal carries an extra field for the hub URL. Portal timeout is 300 s, after which it shows wifi / failed / restarting… and reboots.
  3. Identity. Polls hubUrl + "/d/" + WiFi.macAddress() — uppercase, with colons. The hub normalises to lowercase hex with separators stripped, so 24:0A:C4:C6:03:00 and 240ac4c60300 are the same device.
  4. OTA. Hostname "solo-" + last 6 of the MAC, password from the gitignored solo-secrets.h. Serviced by ArduinoOTA.handle() every loop, with an upload-progress arc drawn on the ring.
  5. Loop. Fetch /d/{mac}, parse into a StaticJsonDocument<768>, redraw only when the frame signature changed, reconnect WiFi if dropped, sleep p seconds.

Anti-flicker

One lastSig string covers layout, the formatted value, its colour, the stale flag, w, and the ring bucketed to a whole percent — 1% is 3.6°, finer than drawArc's 4° dot spacing, so float jitter from the hub cannot make the panel twitch.

Staleness is decided on the device

The firmware computes stale = a > max(300, 5 × p) and ignores the slot's stale_seconds entirely. At p = 10 that is a flat 300 s. See the device contract for how the hub now steers that verdict with a synthesised a.

Claiming

On first check-in the hub mints a 6-character code from a 32-character ambiguity-free alphabet (no O/0, no I/1) and returns {"claim":true,"code":"…"}. The panel renders it and a human types it into the console. The code is stable across polls until claimed, then nulled.

03The drawing spec

render() in console/panel.html is the canonical spec, and the firmware is a port of it — not the reverse. Geometry, colours, sizes, y positions, fitSize and the staleness rule are frozen on both sides. Change one and you must change the other, or the preview stops telling the truth.

Text metrics

The Adafruit GFX default font advances 6 px per character and stands 8 px tall, both multiplied by size. Everything is centred on x = 120. fitSize(s, maxW, cap) = clamp(floor(maxW / (6 × len)), 1, cap) picks the largest size that fits. centerText sets setTextWrap(false) on the device — GFX defaults wrapping on and it affects getTextBounds() too, so without it long text folds onto a second line instead of clipping like the canvas.

Colours (RGB565 → hex)

Threshold colouring assumes higher is worse: v >= th[1] red, v >= th[0] amber, else green. th is dropped entirely unless both warm and hot are set, so the firmware can test th.isNull().

Layout geometry

LayoutDraws
bigvalue at y 120, fitSize(190, cap 9)
topt at y 74 size 2 · value at y 132, fitSize(190, 7)
bottomvalue at y 108, fitSize(190, 7) · b at y 168 size 2
botht y 68 size 2 · value y 120 fitSize(180, 6) · b y 174 size 2
fracv y 86 fitSize(150, 5) · rule rect(70, 119, 100, 2) · w y 154 fitSize(150, 5)
arta 32×32 bitmap — see pixel art

Non-metric frames: claim draws a muted 1 px ring at r 114, "claim code" y 78 size 1, the code y 120 size 3 in accent, "enter in web console" y 162 size 1. Waiting draws "assigned" y 96, -- y 128 size 4, "waiting for data" y 168. Stale adds the word "stale" at y 208 size 1 in warn.

The ring

drawArc(start, sweep, r, colour, dotR) steps every 4° and stamps filled circles — it is a dotted arc, not a stroked path. The progress ring draws a 2 px track over the full 360° at r 114, then a 3 px red sweep from twelve o'clock for r / 100 × 360, skipped below 2° so a rounding artefact cannot leave a lone dot. It is red at every percentage; that is a known rough edge, not a bug.

REMOVED IN REV J. The progress ring is gone. /d/ no longer emits r, and the console has no control for it. Its scale lived on the slot (ring_max, “describes the data”) while its on/off flag lived on the device (ring, “describes the screen”) — the split nobody could explain without a diagram. drawArc() survives in both the firmware and render.js, still drawing the claim-screen border and the OTA progress sweep. Both DB columns survive too, unread: dropping a column in SQLite means rebuilding the table. The paragraph above describes behaviour that no longer ships.
Historical note. Firmware REV B drew a delta ring (±150° at ±0.5 of d) and a % / 24h line. REV C replaced both with the 0–100 progress ring — their absence is the quickest way to tell the two apart on the glass. console/device.html, the simulator, used to carry that older port; as of REV J it calls the shared render.js like every other page, so there is no longer a second copy of the spec to distrust.

04The hub

A single Cloudflare Worker, solo-hub, over a D1 database named solo, with a cron trigger every minute. No framework, no dependencies — the GA4 service-account JWT is signed with WebCrypto by hand. Currently rev Q (GET /health reports it, along with how many slots pull their own data).

The minute trigger is a resolution, not a cadence: since rev L each slot carries its own pull_interval and the tick only fetches what is due. A coarser trigger rounded every slot's cadence up to a multiple of itself — the old 3-minute tick delivered a 5-minute interval every 6 minutes.

Auth

Two secrets: ADMIN_KEY for everything under /admin, and INGEST_KEY for writes. The admin key also satisfies ingest, which is why the console can push a test value with the key you already gave it. Both are Bearer tokens. Comparison fails closed when either side is falsy, so an empty secret matches nothing — including an empty token.

CORS is wide open (Access-Control-Allow-Origin: *, methods GET POST PATCH PUT DELETE OPTIONS, headers Authorization Content-Type) because the console is a static page on a different origin. PATCH had to be added explicitly — without it the browser preflight fails and every device edit silently dies.

Routes

RouteAuthPurpose
GET /none{ok, service, rev}
GET /healthnone{ok, rev, slots, pulled, ts}
POST /ingest/:slotingest{value?, value2?, ts?} — either number alone
GET /m/:slotingestfull metric, verbose keys, delta + state, pull status
GET /d/:macnonethe device poll — compact keys, see below
GET /admin/slotsadminlist
POST /admin/slotsadminupsert — overwrites every column, pull spec included
GET /admin/pullersadminrev L — the pull-kind registry: params, and whether the secret each needs exists
POST /admin/slots/:name/pulladminrev L — fetch now, same path the cron uses. 502 + the source's own reason on failure
DELETE /admin/slots/:nameadminslot + its history
GET /admin/devicesadminlist
PATCH /admin/devices/:idadminpatches only the keys present
DELETE /admin/devices/:idadminforget
POST /admin/claimadmin{code, slot} — looks up by claim code
GET /admin/pulls/:slotadminrev M — the attempt log, newest first. ?minutes=10, max 48 h
GET /admin/history/:slotadmin?hours=24, max 48

PATCH /admin/devices/:id accepts slot (name or null), layout, ring, poll_seconds (5–600), label, top_text, bottom_text, mode, dbg_value and dbg_value2. It validates the layout enum, the poll range, the mode enum strictly ("DEBUG" and true are both rejected) and that the slot exists. It exists because /admin/claim looks devices up by claim_code, which is NULL after claiming — so re-pointing a claimed panel was impossible before it.

Debug mode

Mode is per device, and so are the debug numbers. In debug the hub changes exactly four things, all read-side: v ← dbg_value ?? slot.value, w ← dbg_value2 ?? slot.value2, a ← 0, and r computed from the debug value. Nothing is written to slots or history, so a debugging session cannot corrupt the real series or skew the 24 h delta, and flipping back to live needs no cleanup. Per-column fallback is deliberate: it lets you override only the numerator of a frac. dbg_value: 0 is a real zero, not a clear — send null to clear.

The /d/ key set is byte-for-byte the same shape in both modes, which is precisely why debug mode needed no firmware change.

05The device contract — /d/{mac}

Single-letter keys, because the firmware parses into a fixed StaticJsonDocument<768>. Overrun it and deserializeJson returns NoMemory, the parse branch fails, and nothing is drawn at all — a silent freeze on the last frame rather than an error. A real claimed payload measures around 100 bytes; Panel meters every poll against 768.

KeyTypeMeaning
claimbooltrue → draw the claim screen and stop
codestringthe 6-character claim code
vnumberthe value. null with claim falsy → "waiting for data"
wnumbersecond number; only frac uses it
aintthe age the firmware must judge staleness with — synthesised, see below
taintthe true age in seconds, for humans and the console
pintpoll seconds, clamped 5–600
ystringlayout — big top bottom both frac art; absent → bottom
lstringlabel (kept for REV B, which has no t/b)
ustringunits — parsed but drawn nowhere; no layout has room
th[warm,hot]omitted unless both are set
dfloat24 h delta as a fraction (0.125 = +12.5%)
r—Gone in REV J. Never emitted; the ring was removed.
tstringtop text, ≤ 24 chars, clipped server-side
bstringbottom text, same

The ar key (art_rev) was removed with pixel art in rev K — the Worker no longer sends it.

The split layout (firmware rev D)

Two labelled numbers side by side, divided by a vertical rule — built for an A/B pair, which frac cannot express because it draws no text at all. It needed no new wire keys: v and w are the two numbers, and t / b become the left and right labels rather than top and bottom.

Geometry: halves centred on x = 60 and x = 180 with 96 px of drawing width each, numbers at y = 104 capped at text size 5 (a 3-char number is 90 px, which just clears the rule), labels at y = 148 at size 2. The rule runs y = 66..174 rather than full height — the glass is a circle and has no corners. Thresholds colour the left number only, the same convention frac uses for its numerator.

Firmware older than rev D degrades split to bottom and draws v alone, because the layout guard falls through to the default. That is a safe rollout, not a bug — but it means a panel that has not been reflashed shows one number where you expect two.

Rotation — several faces on one panel (rev N)

A device with a rotation list cycles through it, and this is entirely hub-side: the firmware re-reads every key on every poll, so a different slot's numbers arriving is indistinguishable, to it, from that slot having changed. The /d/ key set is identical. The face is picked by wall clock (floor(now / rotate_seconds) % faces), so two panels showing the same list stay in step and a reboot resumes mid-cycle.

Three things that are easy to get wrong here:

A face whose slot has been deleted falls back to the device's own claimed slot rather than dropping to a claim screen, which would read as "this panel was forgotten".

Three states, not two (rev O). rotate_on = 0 draws the device's own columns and keeps the list; a face with enabled:false is kept but skipped; and if no face is active the panel falls back to its single face exactly as if cycling were off. One active face pins that face and is not "cycling", so it keeps the device's own poll rate rather than the faster rotation one.

a versus ta — read this before "fixing" either

Since rev I, a is not "the age of the value". It is "the age as the firmware must interpret it": a deliberately synthesised number whose only job is to make the firmware's own hardcoded rule stale = a > max(300, 5 × p) land on the verdict the hub has already reached from the slot's stale_seconds.

Why: the firmware's threshold is derived from its poll rate, so at p = 10 it is a flat 300 s — while a GitHub Actions cron on a five-minute schedule routinely runs several minutes late. The panel therefore read "stale" most of the day while working perfectly. Meanwhile the field that does describe the data's cadence, the slot's stale_seconds, was invisible to the panel. ta carries the truth for display.

Rev L removes the cause for pulled slots — the hub owns the schedule, so a 300 s interval is actually 300 s — but the encoding stays: a source can still fail, and the hub's verdict is still the only one that knows about stale_seconds.

The encoding, since a is thresholded and never rendered:

  1. Recompute the firmware's own threshold, thr = max(300, 5 × poll_seconds).
  2. Take the true age — for frac, the older of the two halves; null (never pushed) counts as stale.
  3. Reach the verdict from the slot: stale = trueAge > stale_seconds.
  4. Emit an a that produces that verdict — when fresh, the real age if it is already under thr, otherwise clamped safely below it; when stale, a value comfortably above thr, keeping the real age whenever that is already higher so the number stays as honest as it can.

ta is additive and REV B/C ignore unknown keys, so none of this needed a reflash.

For any console or tool: show ta ?? a as the age, and derive the stale indicator from a. In debug both are 0. Treat ta as optional — older hub revisions do not send it.

The frac rule

For frac, /d/ reports the older of the two ages, so a half that stopped updating still shows stale. Skipped in debug, where a is pinned to 0 on purpose.

Never send spark to the device. A bulk array of history samples is the one thing that can breach even 768 bytes, and deserialisation fails closed to a frozen frame. History is server-side only, exposed through /admin/history.

06Data model

Five tables in D1. meta is a key/value scratchpad — its only current use is caching the GA4 access token.

slots — one row per metric

ColumnNotes
nameprimary key, url-safe [a-z0-9_-]
ownerdefaults 'me'; pre-wired for multi-tenancy, unused
label, units, decimalsunits and decimals are console-only — the firmware has its own fmtValue()
sourcewebhook | github | ga4 — a label only since rev L. Nothing in the Worker branches on it; whether a slot is pulled is decided by pull_kind alone
ga4_metrice.g. activeUsers. Legacy: the ga4.metric puller falls back to it when pull_config carries no metric
pull_kindrev L — which fetcher runs, e.g. github.review_queue. NULL = push-only, and that is the cron's only discriminator
pull_configrev L — that fetcher's params as JSON. Re-validated against the kind's spec on every write and every pull, unknown keys dropped. Never put a secret here — /admin/slots returns it verbatim
pull_intervalrev L — seconds between pulls, 60–86400, default 300. The real cadence; the cron trigger is just the resolution
pull_ts, pull_ok, pull_errrev L — last attempt (not last success), its outcome, and the upstream's own reason. The due check reads pull_ts, so a broken source retries on its interval instead of every tick
warm, hotthresholds; both or neither
stale_secondsthe hub's freshness window. Does not reach the firmware directly — it steers a
value, tsthe current primary number and when it landed
value2, ts2the second number; ts2 exists so a stalled denominator is detectable
ring_maxVestigial since REV J. Column still exists, never read or written — the ring is gone and SQLite cannot drop a column without rebuilding the table

devices — one row per panel

ColumnNotes
idlowercase MAC, separators stripped — the primary key
slotNULL until claimed
claim_codenulled on claim. No UNIQUE constraint; a collision would bind an arbitrary device (32⁶ ≈ 1e9 — noted, not worth building)
poll_seconds5–600. A self-registering device is written 10 explicitly by the Worker (DEFAULT_POLL_SECONDS, rev I) — the schema's own DEFAULT 15 is vestigial and no longer reached. Existing rows keep whatever they already have.
layout, ringdisplay properties, hence per device
top_text, bottom_textfall back to the slot's label
art, art_revbase64 bitmap and its revision counter
mode, dbg_value, dbg_value2per-device debug; NULL falls back to the slot's real value
rotationrev N — ordered JSON array of faces to cycle: {slot, layout?, top_text?, bottom_text?, enabled?}. enabled:false (rev O) keeps a face but skips it; it is stored only when false, so absent means active and every rev N face keeps working. NULL or [] = one face, exactly as every pre-rev-N device behaves. Per-face keys override the device columns; anything omitted falls back to them
rotate_onrev O — the master switch, 0/1. 0 draws the device's own slot/layout/text columns and KEEPS the face list, so switching off costs you no configuration. NULL counts as on
rotate_secondsrev N — seconds per face, 5–3600. Not the poll rate: while cycling the hub serves a small constant p of rotate_seconds / 4
first_seen, last_seen, fwfw is preserved by COALESCE — the firmware sends no ?fw=

pull_log — one row per attempt (rev M)

(slot, ts, ok, value, value2, ms, err), WITHOUT ROWID, same rolling 48 h as history and pruned beside it. It exists because the two questions you actually have when a panel goes quiet were both unanswerable without it: history records only writes that succeeded and only the primary number, while slots.pull_err keeps only the most recent failure — one later success erases it, so an intermittent source left no trace at all.

Written in the same D1 batch as the slots.pull_ts/ok/err update, so the log and the current state cannot disagree. Deleting a slot deletes its log with it — there are no foreign keys, so that is explicit in the delete handler.

The console shows the last ten minutes under Source → Recent checks, fetched only while that fold is open. The gap column is computed client-side against the previous row and turns amber past 1.5x pull_interval — a missed tick is invisible any other way.

history

(slot, ts, value), WITHOUT ROWID, rolling 48 hours, pruned by the cron. It backs the 24-hour delta. Only the primary series is recorded — value2 has no history.

Why config is split. Anything describing the data is on the slot (thresholds, ring scale, source, freshness). Anything describing one screen is on the device (layout, ring on/off, text, poll rate, debug). That is what lets two panels render the same slot completely differently, and it is the rule to apply when adding a field.

07Sources and history

A number reaches a slot one of two ways: it is pushed to /ingest/{slot} from outside, or the hub pulls it on a schedule. Both still work; a slot's pull_kind decides which one applies to it.

Pull — the hub fetches its own data (rev L)

Set pull_kind and pull_config on a slot and the cron fetches it every pull_interval seconds. Nothing outside Cloudflare is involved: no workflow in someone else's repo, no ingest key copied into a repo secret, and no dependence on GitHub's best-effort scheduler — which is what made the panel read stale most of the day and forced the a-as-verdict hack in rev I.

KindNeedsNumbers
github.review_queueGITHUB_TOKEN value = open PRs where reviewer is a requested reviewer, value2 = total open PRs. Params owner, repo, reviewer, include_drafts
ga4.metricGCP_SA_EMAIL, GCP_SA_KEY, GA4_PROPERTY_ID value = the metric. Param metric, falling back to the legacy ga4_metric column
mixpanel.funnel_conversion (rev Q) MIXPANEL_SA_USER, MIXPANEL_SA_SECRET value = segment_a's conversion as a percentage, value2 = segment_b's — the two halves of split. Params project_id, funnel_id, segment_a, segment_b, days (rolling window, 30), length/length_unit (the funnel's own conversion deadline — must match the saved report). Leave the segments empty for $overall

The Mixpanel fetcher re-runs the funnel rather than reading a cached number, so it gets a 45 s budget of its own where every other pull has 10 s — 2.5–5.6 s from a laptop but 16 s from the Worker, and the very first call blew through 10 s. An empty data object is treated as an error, not 0 %: it is equally what a wrong project id, the US host, or a dead window looks like. Queries go to eu.mixpanel.com; the US host answers 200 with nothing.

GET /admin/pullers is the authority on what exists — the console builds its form from it and holds no copy of the param specs, so a kind added to the Worker appears with its own fields and no console change. Adding a source means adding one entry to PULLERS.

Three deliberate refusals in the GitHub fetcher, each of which would otherwise put a plausible wrong number on the glass:

Reviews requested via a team land in requested_teams and are not counted — the same limitation the Actions workflow had, kept so the two agree.

Push — /ingest/{slot}

curl -fsS -X POST https://solo-hub.joachim-609.workers.dev/ingest/prs \
  -H "Authorization: Bearer $INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": 3, "value2": 12}'

Use -f. Without it curl exits 0 on a 401 and you get a green build with a panel quietly going stale — the worst failure mode there is. An optional "ts" (unix seconds) backfills. Every write appends a history row.

The cron discriminator is pull_kind IS NOT NULL — never source. This replaces the old rule ("the cron must never learn about github", when the query was WHERE source = 'ga4') and exists for the same reason: a slot fed by a GitHub Actions workflow carries source = 'github' with no pull_kind, so the cron cannot overwrite a value that was just pushed. Keying off source again would resurrect exactly that bug. The two are free to disagree — a github slot may be push-fed or hub-pulled.

A pull that fails is caught per slot, so one broken source never stops the others or the history prune. Before a source's secret exists its slot simply logs pull … failed: GITHUB_TOKEN is not set on the Worker every interval — expected, not a fault.

The prune (history and pull_log, batched) runs on the ten-minute marks only. At a minute-by-minute tick, running two whole-table deletes every time would be ten times the D1 writes for no benefit.

The 24-hour delta is a percentage of yesterday's value, so metrics that sit near zero produce wild figures. Expected, not a bug.

08Pixel art removed

REMOVED IN REV K (2026-08-13). Pixel art is gone from the firmware, the hub and the console — no art layout, no /art/{mac} route, no editor page. It was built and verified end to end first, so ART-CONTRACT.md is kept as the record of a format that worked. devices.art and art_rev survive in D1 unread. Everything below describes what was removed.

32×32, one 4-bit palette index per pixel, two pixels per byte, first pixel in the HIGH nibble, row-major, rows byte-aligned at row × 16 → exactly 512 bytes. Drawn as 7×7 px cells at x = 8 + col×7, y = 8 + row×7 — 224 px centred on the 240 px panel.

The 512 bytes never travel through JSON. /d/ carries only ar (the art_rev) and only for art-layout devices; the device re-fetches GET /art/{mac} — raw bytes, no auth — whenever that number changes. The palette is the firmware's own 16 colours; the full table with RGB565 values and a golden test vector is in ART-CONTRACT.md.

The panel is circular: 100 of the 1024 cells are fully invisible and 68 are clipped. dims them and shows a live "off glass" count.

Not implemented on the device yet. Firmware REV C does not render layout art — a device set to it still receives v and degrades to a number rather than a blank screen. The editor, the hub routes and the format are all done; ART-CONTRACT.md ends with the implementation list and a 9-point checklist.

09The console

Static HTML on Cloudflare Pages — no build step, no CDN, no webfonts. One shared stylesheet, solo.css, linked relatively; the directory is served as-is.

PageOwns
index.htmlMy devices — one card per panel with a live thumbnail, plus Add new device. Slots are no longer a UI concept; adding a device creates one silently from the name you type.
panel.htmlOne device, everything about it: live preview, live/debug, one of three modes (Number / Fraction / Pixel art), helper text, thresholds, poll rate, forget. No ring, no manual push — in live mode nothing can be set by hand, which is the point of the toggle.
solo.js · render.jsShared. solo.js holds the admin key (asked once, kept in localStorage), api(), toast(). render.js holds the canonical drawing spec that the firmware ports — it moved out of panel.html in REV J because three pages need it and one had drifted to a stale copy.
guide.htmlPlain-language guide for someone who just wants a number on a screen.
device.htmlBrowser simulator — polls the real hub with a JS port of the drawing code. Any MAC works; a fresh one self-registers and shows a claim code. Not a daemon: it only runs while the tab is open.
docs.htmlThis page.

Admin key handling. Panel and Art keep it in localStorage under solo_admin_key, so it is entered once per browser and re-enterable with the Key button. Console keeps it in the form only, for that tab. Nothing is ever put in a URL.

Dirty-state saves. Panel remembers each row exactly as the hub gave it, enables Save only on a real difference, marks changed fields, and sends only the changed keys. A 15-second background reconcile adopts hub-side changes when the form is clean and raises a conflict banner when it is not — because the alternative is a form left open for an hour writing a stale poll_seconds back over reality.

10Operating it

Always invoke wrangler through the local binary — a bare wrangler is not installed, and pnpm wrangler fails on a freshness check.

# hub
./node_modules/.bin/wrangler deploy

# console
./node_modules/.bin/wrangler pages deploy console \
  --project-name solo-console --branch main

# schema / migrations — --remote is load-bearing
./node_modules/.bin/wrangler d1 execute solo --remote --file=solo-hub-schema.sql

# firmware, over the air; the board only needs USB power
arduino-cli upload -p solo-c60300.local --protocol network \
  --fqbn esp32:esp32:esp32:PartitionScheme=min_spiffs solo-firmware

Secrets

Keys live outside the repo in ~/.solo-admin-key, ~/.solo-ingest-key and ~/.solo-ota-password (mode 600). The OTA password is inlined into the sketch at build time through solo-firmware/solo-secrets.h, which is gitignored — a fresh clone will not compile until you recreate it.

wrangler secret put does not work in this environment. It prints ✨ Success! while storing a value that matches nothing — reproduced twice, both through a non-TTY shell and with the value piped in. Use secret bulk with a JSON file instead, and verify by using the key: you cannot read a secret back, and an empty secret is indistinguishable from a wrong one from outside (every probe 401s either way).

Repo layout

console/this site — the six pages above plus solo.css
solo-hub-worker.jsthe whole hub, one file
solo-hub-schema.sql
solo-hub-migrate-rev{F,G,H}.sql
D1 schema and forward migrations
solo-firmware/the sketch; folder name must match the .ino
examples/ready-to-copy GitHub Actions workflows and a push script
SOLO-STATE.mdoperational log and authority on contracts
ART-CONTRACT.mdthe 512-byte bitmap spec, palette table, test vector

11Traps

Each of these cost real time at least once. They are worth reading before debugging anything that "should" work.

SymptomCause
Every /admin call 401s after setting a secret secret put stored nothing. Use secret bulk.
Build fails: text section exceeds available space Missing PartitionScheme=min_spiffs. Do not switch to huge_app — it kills OTA.
Board reboots while joining WiFi; portal never completes Opening /dev/cu.usbserial-0001 asserts DTR and resets this board. The usual "cu doesn't reset" rule does not hold here. Observe through /admin/devices instead.
Serial is silent during setup Normal. Nothing is printed between Serial.begin() and a successful WiFi join.
Panel still reads SOLO-setup That screen is painted once, before autoConnect(), and never repainted. It means only "autoConnect has not returned" — not that the portal is up or the join failed.
Panel blank or frozen on the last frame Payload over 768 bytes → NoMemory → nothing drawn. Check the byte meter in Panel.
Panel shows "stale" while the feed is healthy The firmware's window is max(300, 5 × p). Since rev I the hub steers a to match the slot's stale_seconds; on older revisions raise poll_seconds or push more often.
A slot field you never touched reverted to a default POST /admin/slots overwrites every column. Always send the full merged row.
Panel preview said "poll failed" while the device was answering perfectly Two bugs compounding. previewWith() dereferenced a null querySelector('#layoutSeg button[aria-pressed=true]') — unset before the first form write, and permanently unset for layout art, which has no button. And poll() wrapped the fetch and the draw in one try, so the draw's TypeError was reported in the field that describes the fetch. It read as an unreachable device and cost two debugging sessions.
Device edits fail only in the browser PATCH missing from Access-Control-Allow-Methods → preflight fails.
Cron never fires A deploy that errored at the workers.dev publish step uploads the script but never registers the trigger. A successful deploy prints schedule:.
TLS handshake failure against the hub The workers.dev Production toggle off while Preview is on (Workers & Pages → solo-hub → Domains) presents as a handshake error, not a 404.
Scripted requests get a blanket 403 Cloudflare bot filtering rejects Python-urllib. Send a browser user-agent. The ESP32's own UA was checked deliberately and is not blocked.
OTA cannot find solo-*.local mDNS needs the same LAN. Guest networks usually enable client isolation, which blocks it — a device on a guest SSID may be unreachable for OTA.
A comment breaks the Worker deploy A literal cron expression inside a /** */ block closes the comment early. Write "every 3 minutes" in prose.
curl says a deployed page is empty, or a grep over it finds nothing Pages serves extensionless canonical URLs and 308s /docs.html → /docs. Without -L, curl writes a zero-byte body and every check over it comes back false — a verification that vacuously passes, which is worse than one that fails. Use curl -sL, or something that follows redirects by default.
The lesson worth more than the line. Keep fetching and drawing in separate try blocks, and never let a failure in one write into a field that describes the other — an error message pointing at the wrong subsystem is worse than no message, because it sends you to debug something that was never broken. panel.html now polls in one try and draws in safeRender(), which reports into its own #drawErr line and cannot touch the payload view. The same reasoning applies to any "optimistic local preview" helper: give it a total function with an explicit fallback chain — currentLayout() falls back to the device row, then to what /d/ reports, then to the firmware's default — rather than trusting that the DOM is already in the state you expect.