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.
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.
/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.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.
| Signal | GPIO | Note |
|---|---|---|
| SCK | 18 | VSPI default |
| MOSI | 23 | does not exist on an ESP32-C3 — proof this is a classic ESP32 |
| DC | 2 | |
| CS | 15 | |
| RST | 4 | |
| BL | -1 | backlight left unconnected on this 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.Preferences (namespace solo, key hub).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.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."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./d/{mac}, parse into a StaticJsonDocument<768>,
redraw only when the frame signature changed, reconnect WiFi if dropped, sleep
p seconds.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.
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.
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.
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.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.
0xFFFF #FFFFFF — value with no thresholds0x2E8B #29D35A — below warm0xFD20 #FFA600 — at or above warm, and the "stale" word0xF800 #FF0000 — at or above hot, and the ring sweep0x5AEB #5A5D5A — labels, and the value when stale0xFB00 #FF6100 — claim code, OTA arc0x18E3 #181C18 — the ring's unfilled trackThreshold 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 | Draws |
|---|---|
| big | value at y 120, fitSize(190, cap 9) |
| top | t at y 74 size 2 · value at y 132, fitSize(190, 7) |
| bottom | value at y 108, fitSize(190, 7) · b at y 168 size 2 |
| both | t y 68 size 2 · value y 120 fitSize(180, 6) · b y 174 size 2 |
| frac | v y 86 fitSize(150, 5) · rule rect(70, 119, 100, 2) · w y 154 fitSize(150, 5) |
| art | a 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.
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.
/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.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.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.
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.
| Route | Auth | Purpose |
|---|---|---|
| GET / | none | {ok, service, rev} |
| GET /health | none | {ok, rev, slots, pulled, ts} |
| POST /ingest/:slot | ingest | {value?, value2?, ts?} — either number alone |
| GET /m/:slot | ingest | full metric, verbose keys, delta + state, pull status |
| GET /d/:mac | none | the device poll — compact keys, see below |
| GET /admin/slots | admin | list |
| POST /admin/slots | admin | upsert — overwrites every column, pull spec included |
| GET /admin/pullers | admin | rev L — the pull-kind registry: params, and whether the secret each needs exists |
| POST /admin/slots/:name/pull | admin | rev L — fetch now, same path the cron uses. 502 + the source's own reason on failure |
| DELETE /admin/slots/:name | admin | slot + its history |
| GET /admin/devices | admin | list |
| PATCH /admin/devices/:id | admin | patches only the keys present |
| DELETE /admin/devices/:id | admin | forget |
| POST /admin/claim | admin | {code, slot} — looks up by claim code |
| GET /admin/pulls/:slot | admin | rev M — the attempt log, newest first. ?minutes=10, max 48 h |
| GET /admin/history/:slot | admin | ?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.
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.
/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.
| Key | Type | Meaning |
|---|---|---|
| claim | bool | true → draw the claim screen and stop |
| code | string | the 6-character claim code |
| v | number | the value. null with claim falsy → "waiting for data" |
| w | number | second number; only frac uses it |
| a | int | the age the firmware must judge staleness with — synthesised, see below |
| ta | int | the true age in seconds, for humans and the console |
| p | int | poll seconds, clamped 5–600 |
| y | string | layout — big top bottom both frac art; absent → bottom |
| l | string | label (kept for REV B, which has no t/b) |
| u | string | units — parsed but drawn nowhere; no layout has room |
| th | [warm,hot] | omitted unless both are set |
| d | float | 24 h delta as a fraction (0.125 = +12.5%) |
| — | Gone in REV J. Never emitted; the ring was removed. | |
| t | string | top text, ≤ 24 chars, clipped server-side |
| b | string | bottom text, same |
The ar key (art_rev) was removed with pixel art in rev K —
the Worker no longer sends it.
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.
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.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:
p is not poll_seconds while cycling. A device
configured to poll every 180 s would sit on one face for three minutes of a 20 s rotation, so
the hub serves rotate_seconds / 4 instead. Everything downstream — including the
staleness encoding below — must use that value, because the firmware derives its own threshold
from the p it was handed.loop() sets nextPoll before
it parses the response, so a new p only takes effect one cycle later and the
timing would drift. A constant is immune to that lag.big faces showing the same number look like rotation has stopped. Correct
behaviour, surprising symptom.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" eitherSince 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:
thr = max(300, 5 × poll_seconds).frac, the older of the two halves;
null (never pushed) counts as stale.stale = trueAge > stale_seconds.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.
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.frac ruleFor 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.
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.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| Column | Notes |
|---|---|
| name | primary key, url-safe [a-z0-9_-] |
| owner | defaults 'me'; pre-wired for multi-tenancy, unused |
| label, units, decimals | units and decimals are console-only — the firmware has its own fmtValue() |
| source | webhook | 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_metric | e.g. activeUsers. Legacy: the ga4.metric puller falls back to it when pull_config carries no metric |
| pull_kind | rev L — which fetcher runs, e.g. github.review_queue. NULL = push-only, and that is the cron's only discriminator |
| pull_config | rev 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_interval | rev L — seconds between pulls, 60–86400, default 300. The real cadence; the cron trigger is just the resolution |
| pull_ts, pull_ok, pull_err | rev 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, hot | thresholds; both or neither |
| stale_seconds | the hub's freshness window. Does not reach the firmware directly — it steers a |
| value, ts | the current primary number and when it landed |
| value2, ts2 | the second number; ts2 exists so a stalled denominator is detectable |
| Vestigial 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| Column | Notes |
|---|---|
| id | lowercase MAC, separators stripped — the primary key |
| slot | NULL until claimed |
| claim_code | nulled on claim. No UNIQUE constraint; a collision would bind an arbitrary device (32⁶ ≈ 1e9 — noted, not worth building) |
| poll_seconds | 5–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, | display properties, hence per device |
| top_text, bottom_text | fall back to the slot's label |
| art, art_rev | base64 bitmap and its revision counter |
| mode, dbg_value, dbg_value2 | per-device debug; NULL falls back to the slot's real value |
| rotation | rev 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_on | rev 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_seconds | rev 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, fw | fw 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.
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.
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.
| Kind | Needs | Numbers |
|---|---|---|
github.review_queue | GITHUB_TOKEN |
value = open PRs where reviewer is a requested reviewer,
value2 = total open PRs. Params owner, repo,
reviewer, include_drafts |
ga4.metric | GCP_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:
0 rather than failing — which reads as
inbox zero.ts stay put,
the panel ages out through stale_seconds, and the reason lands in
pull_err where the console shows it. A zero would look like real data.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.
/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.
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.
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.
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.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.
| Page | Owns |
|---|---|
| index.html | My 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.html | One 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.js | Shared. 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.html | Plain-language guide for someone who just wants a number on a screen. |
| device.html | Browser 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.html | This 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.
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
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).| console/ | this site — the six pages above plus solo.css |
| solo-hub-worker.js | the 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.md | operational log and authority on contracts |
| ART-CONTRACT.md | the 512-byte bitmap spec, palette table, test vector |
Each of these cost real time at least once. They are worth reading before debugging anything that "should" work.
| Symptom | Cause |
|---|---|
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. |
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.