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 that needs a terminal is folded into an for engineers block you can expand — skip them entirely and the instructions still work.
Everything lives in the cloud except the display itself. There is no server to maintain, 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. Where you create metrics, set what's displayed, and link displays. |
| 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. If you later want it 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 about how it should look.
| Field | What it does |
|---|---|
| Name | Short id used in links and by whatever sends the data.
Lowercase letters, numbers, - and _ only. |
| Label | The words shown above the number on the display. Keep it short — the screen is small. |
| Units | Shown under the number, e.g. % or
orders. Leave blank for none. |
| Decimals | How many decimal places the console shows. |
| Warm above / Hot above | Colour thresholds. Below warm the number is green, at or above warm it turns amber, at or above hot it turns red. Leave both blank for a plain white number. |
| Source | webhook — something pushes values in. ga4 — the hub fetches from Google Analytics itself every 3 minutes. |
| Stale after | How many seconds old the value can get before the display marks it stale. Set this comfortably longer than how often the value is sent. |
Slots are rows in a D1 (SQLite) table. decimals is honoured by the console
only; the firmware has its own fmtValue() which switches to
k/M above 10,000 and 1,000,000. 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}'
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 running on a server you already have. It sends one number; the hub stores it.
Set the slot's Source to webhook. You'll need the ingest key —
a password that lets a service write values. It's separate from the admin key so you can
hand it to an automation without giving away control of the console.
GA4 is built in. Set Source to ga4 and give the metric name
(e.g. activeUsers). The hub fetches it every 3 minutes by itself — nothing
else to set up, once the Google credentials are in place.
One number per request. The slot must already exist and be source='webhook';
unknown slots return 404, and a ga4 slot would be overwritten by the next
cron tick. 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.
A ready-to-copy GitHub Actions workflow lives in the repo at
examples/github-actions-ingest.yml.
A new display knows nothing about you. The first time it reaches the hub it's 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 — there's no letter O or digit 0, no letter I or digit 1. If a character looks ambiguous, it's the one that isn't a number.
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, its label, and the 24-hour change.
Can't reach the hub. Usually wifi. It retries by itself.
The ring shows how the number compares with 24 hours ago. It sweeps clockwise and green when the number is up, anticlockwise and red when it's down. A half-circle sweep means roughly a 50% change. The same figure is written underneath as a percentage.
The word stale appears when the value is older than expected, and the number turns grey. It means the display is fine but nothing fresh is arriving — check whatever is meant to be sending the data.
| Symptom | Most likely cause |
|---|---|
| Console says unauthorized | Wrong admin key, or a stray space when pasting it. |
| Panel stuck on waiting for first data | The slot exists but nothing has sent a value. Test by pushing one by hand. |
| Panel shows stale most of the time | "Stale after" is shorter than the gap between updates. Raise it above your send interval, with headroom — scheduled jobs often run late. |
| 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. |
| Number jumps to a huge percentage | The 24-hour comparison divides by yesterday's value, so metrics that sit near zero produce wild percentages. 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,"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 (upsert) |
DELETE /admin/slots/:name | admin | Delete + its history |
GET /admin/devices | admin | List devices |
POST /admin/claim | admin | {"code","slot"} |
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 384 bytes — that's the firmware's
fixed JSON buffer. Over it, the device parses nothing and freezes on its last frame
rather than showing an error. The simulator prints payload size on every poll.
Honest list of what this version doesn't do, so nobody loses an afternoon to it.