SOLOguide

How SOLO works

SOLO puts one number on one screen. A small round display sits on your desk and shows a single metric you care about — nothing else competing for attention. This page explains how to get a number onto one.

Written for everyone. The main text assumes no technical background; anything needing a terminal is folded into a for engineers block you can skip entirely. If you are changing how SOLO works rather than using it, read the technical reference instead — contracts, schema, geometry and traps.

01The four pieces

Everything lives in the cloud except the display itself. There is no server to maintain and no machine that has to stay switched on at home.

PieceWhat it is
The hub The brain. Stores your metrics, remembers 48 hours of history, and answers the display when it asks "what should I show?" Runs on Cloudflare.
The console This website. Console holds metrics and devices, Panel configures one display, draws a 32×32 bitmap for it.
The display The physical desk module — a 240×240 round screen. Asks the hub for its number every few seconds and draws it.
The simulator A web page that pretends to be a display. Same drawing code, real data. Use it to design a metric before any hardware exists.
Why it matters: the hub is the only thing that decides what a display shows, so swapping a broken module for a new one is just re-linking it. Nothing is configured on the device itself.

02Quick start — a number on a screen

  1. Create a slot. In Console, fill in "Add or update a slot". A slot is one metric: give it a short name like signups and a label like "signups today".
  2. Give it a number. Either pick a source in Panel and let the hub fetch it for you — GitHub review queues work out of the box — or push the value in from a scheduled job or workflow tool. See Getting data in.
  3. Link a display. Power on a module (or open the simulator). It shows a six-character code. Type that code into the Console's Devices section and pick the slot.
  4. Choose how it looks. Open Panel to pick a layout, turn the progress ring on, and set the text around the number.

The display updates on its own from then on. To show something else, change it in the console — you never touch the device.

03Slots — deciding what to show

A slot is one metric. It holds the current value plus everything that describes the data. Anything describing one screen lives on the device instead, in Panel.

FieldWhat it does
NameShort id used in links and by whatever sends the data. Lowercase letters, numbers, - and _ only.
LabelThe default words shown with the number. Keep it short — only about 17 characters fit across the round glass.
Warm above / Hot aboveColour thresholds. Below warm the number is green, at or above warm amber, at or above hot red. Leave both blank for a plain white number.
Ring maxWhat counts as a full ring. The progress ring sweeps value ÷ ring max. No ring max, no ring.
SourceJust a label for where a number came from — webhook, github or ga4. What actually decides how the number arrives is the Source row in Panel, which shows the vendor and whether the hub pulls it or something else pushes it in. Click Change there to switch.
Units, decimalsUsed by this console. The firmware has its own formatting, switching to k above 10,000 and M above a million.
Stale afterHow old the value may get before the console calls it stale. It does not govern the glass — see Troubleshooting.
The colours assume bigger is worse. That's right for a support backlog or server load. For something where bigger is better, like conversion rate, red would mean you're winning — leave the thresholds blank for those.
For engineers — slot fields

Slots are rows in a D1 (SQLite) table, and POST /admin/slots is an upsert that overwrites every column — always send the full row. Thresholds reach the device as a two-element array th: [warm, hot] and are dropped entirely unless both are set.

curl -X POST https://solo-hub.joachim-609.workers.dev/admin/slots \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"signups","label":"signups today","units":"",
       "decimals":0,"source":"webhook","warm":null,"hot":null,
       "stale_seconds":7200,"ring_max":null}'

04Getting data in

Let the hub fetch it — the simplest option

Open Panel. The Source row shows where the number comes from today — a vendor mark, and whether it is pulled by the hub or pushed in from outside. Click Change, pick a source, fill in its fields, choose how often to check, and save. That is the whole setup: nothing to install, no workflow file, no key to hand out. Check now fetches immediately so you know it works before you walk away.

SourceWhat you give itWhat lands on the glass
GitHub — PRs awaiting my review Owner, repository, your GitHub username Your review queue over the repo's total open PRs — the two numbers the fraction layout stacks
Google Analytics 4 A metric name, e.g. activeUsers That metric's current value

If a source needs credentials the hub does not have yet, the picker says so instead of failing quietly five minutes later. When a check fails, Panel shows the reason the source gave — "no such repo, or the token cannot see it" — and the display keeps its last good number and goes stale rather than dropping to zero.

Push — for anything not in that list

Anything that can make a web request on a schedule can feed a slot: a GitHub Action, a hosted automation tool like n8n Cloud or Zapier, or a script on a server you already have. It sends one number; the hub stores it. Leave the source picker on "pushed in from outside".

You'll need the ingest key — a password that only lets a service write values, so you can hand it to an automation without giving away control of the console.

By hand

Panel can push a value straight into the slot, which is the quickest way to prove the chain works. It can also switch one display to debug and drive it with a slider without touching the real data at all.

For engineers — the ingest endpoint

One or two numbers per request. The slot must already exist; unknown slots return 404, and a slot that fetches its own data would be overwritten at its next check. Every write also appends to the 48-hour history table that backs the 24-hour comparison.

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

Use -f so a bad key fails your job loudly. Without it curl exits 0 on a 401 and you get a green build with a panel quietly going stale — the worst failure mode. An optional "ts" (unix seconds) lets you backfill, and "value2" feeds the second number the frac layout stacks underneath.

Ready-to-copy GitHub Actions workflows live in the repo under examples/.

05Connecting a display

A new display knows nothing about you. The first time it reaches the hub it is given a six-character code, which it shows on screen. Typing that code into the console is what links the two.

  1. Power on the module. After it joins wifi it shows claim code and six characters.
  2. In Console → Devices, enter the code and the slot name.
  3. Press Claim device. Within a few seconds the display switches to your metric.

Codes avoid easily-confused characters — no letter O or digit 0, no letter I or digit 1.

To move a display to a different metric, open Panel → Advanced and change its slot. Forget-and-claim is no longer necessary.

06Reading the panel

The display has four states. Knowing them makes most problems self-diagnosing.

claim code 7F2K9Q enter in web console

Waiting to be linked. Type the code into the Console.

assigned -- waiting for data

Linked, but the slot is empty. Nothing has sent a value yet.

247 signups today

Working. The number and its text, coloured by your thresholds.

hub offline retrying...

Can't reach the hub. Usually wifi. It retries by itself.

The colour of the number

The ring around the edge

The ring is a progress ring: it sweeps clockwise from twelve o'clock in proportion to value ÷ ring max, so a half circle means half way. Turn it on per display in Panel and set the ring max on the slot. No ring max, no ring.

"stale"

The word stale appears, and the number turns grey, when the value is older than expected. The display is fine — something upstream has stopped sending.

07Layouts, art and debug

Everything in this section is set per display in Panel, so two panels can show the same slot completely differently.

LayoutWhat it draws
bigThe number alone, as large as it fits.
top / bottomThe number with one line of text above or below.
bothText above and below a slightly smaller number.
fracTwo numbers stacked as a fraction — value over value2, e.g. "my open PRs / all open PRs". Either half can be pushed on its own.
artA 32×32 bitmap instead of a number, drawn in.

Debug mode stops one display listening to its source and lets you drive it by hand with a number box and a slider. It writes nothing to the slot and nothing to history, so it cannot corrupt the real series — switching back to Live needs no cleanup.

08Troubleshooting

SymptomMost likely cause
Console says unauthorized Wrong admin key, or a stray space when pasting it.
Panel stuck on waiting for data The slot exists but nothing has sent a value. Push one by hand from Panel.
Panel shows stale most of the time The display works out staleness itself from how often it polls — max(300, 5 × poll seconds) — and ignores the slot's "stale after". A slot fed less often than that window reads stale. Raise poll seconds in Panel → Advanced, or push more often.
Sender reports 404 Slot doesn't exist, or the name is misspelled. Names are case-sensitive.
Sender reports 401 Using the admin key where the ingest key is expected, or vice versa.
Screen is blank after flashing Wiring. The pin settings in the firmware must match how the panel is soldered.
The 24-hour figure looks absurd It is a percentage of yesterday, so metrics that sit near zero produce wild numbers. Expected, not a bug.
For engineers — API reference

Base URL https://solo-hub.joachim-609.workers.dev. Two secrets: ADMIN_KEY for everything under /admin, INGEST_KEY for writes; admin also satisfies ingest. All responses are JSON; failures carry {"ok":false,"error":"..."}.

RouteAuthPurpose
GET /healthnoneLiveness + slot count
POST /ingest/:slotingest{"value":N,"value2":N,"ts":?}
GET /m/:slotingestFull metric, verbose keys
GET /d/:macnoneDevice poll — compact keys
GET /admin/slotsadminList slots
POST /admin/slotsadminCreate or update — overwrites every column
GET /admin/pullersadminWhich sources the hub can fetch, and their fields
POST /admin/slots/:name/pulladminFetch this slot now — what Check now calls
DELETE /admin/slots/:nameadminDelete + its history
GET /admin/devicesadminList devices
POST /admin/claimadmin{"code","slot"}
PATCH /admin/devices/:idadminLayout, ring, text, slot, poll, mode, debug values — only the keys you send
DELETE /admin/devices/:idadminForget a device
GET /admin/history/:slotadmin?hours=24, max 48

/d/:mac accepts a MAC in any case with or without separators. Its payload uses single-letter keys and must stay under 768 bytes — the firmware's fixed JSON buffer. Over it, the device parses nothing and freezes on its last frame rather than showing an error. Panel meters every payload against that ceiling.

09Known limits

An honest list, so nobody loses an afternoon to it.