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.
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.
| Piece | What 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. |
signups and a label like
"signups today".The display updates on its own from then on. To show something else, change it in the console — you never touch the device.
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.
| Field | What it does |
|---|---|
| Name | Short id used in links and by whatever sends the data. Lowercase
letters, numbers, - and _ only. |
| Label | The default words shown with the number. Keep it short — only about 17 characters fit across the round glass. |
| Warm above / Hot above | Colour 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 max | What counts as a full ring. The progress ring sweeps
value ÷ ring max. No ring max, no ring. |
| Source | Just 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, decimals | Used by this console. The firmware has its own
formatting, switching to k above 10,000 and M above a
million. |
| Stale after | How old the value may get before the console calls it stale. It does not govern the glass — see Troubleshooting. |
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}'
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.
| Source | What you give it | What 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.
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.
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.
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/.
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.
Codes avoid easily-confused characters — no letter O or digit 0, no letter I or digit 1.
The display has four states. Knowing them makes most problems self-diagnosing.
Waiting to be linked. Type the code into the Console.
Linked, but the slot is empty. Nothing has sent a value yet.
Working. The number and its text, coloured by your thresholds.
Can't reach the hub. Usually wifi. It retries by itself.
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.
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.
Everything in this section is set per display in Panel, so two panels can show the same slot completely differently.
| Layout | What it draws |
|---|---|
| big | The number alone, as large as it fits. |
| top / bottom | The number with one line of text above or below. |
| both | Text above and below a slightly smaller number. |
| frac | Two numbers stacked as a fraction — value over
value2, e.g. "my open PRs / all open PRs". Either half can be pushed on its
own. |
| art | A 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.
| Symptom | Most 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. |
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":"..."}.
| Route | Auth | Purpose |
|---|---|---|
GET /health | none | Liveness + slot count |
POST /ingest/:slot | ingest | {"value":N,"value2":N,"ts":?} |
GET /m/:slot | ingest | Full metric, verbose keys |
GET /d/:mac | none | Device poll — compact keys |
GET /admin/slots | admin | List slots |
POST /admin/slots | admin | Create or update — overwrites every column |
GET /admin/pullers | admin | Which sources the hub can fetch, and their fields |
POST /admin/slots/:name/pull | admin | Fetch this slot now — what Check now calls |
DELETE /admin/slots/:name | admin | Delete + its history |
GET /admin/devices | admin | List devices |
POST /admin/claim | admin | {"code","slot"} |
PATCH /admin/devices/:id | admin | Layout, ring, text, slot, poll, mode, debug values — only the keys you send |
DELETE /admin/devices/:id | admin | Forget a device |
GET /admin/history/:slot | admin | ?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.
An honest list, so nobody loses an afternoon to it.
frac). That is the design,
not an oversight.