HTN OS — badge proxy protocol and API
HTN OS is a replacement firmware for the Hack the North 2026 Hacker Badge
(ESP32-C3-MINI-1-N4). Flash it once from https://solana-htn.com/badge, join
Wi-Fi from the badge's settings screen, and the badge registers itself with the
badge service and gets a permanent five-character HTN-ID (for example
xb2b9). From then on every peripheral — the 320×240 screen, six RGB LEDs,
nine buttons, the accelerometer, and the NFC reader — is reachable over HTTP,
so you build badge ↔ server and badge ↔ badge apps by talking to the service,
never by re-flashing.
your code ──HTTPS/WSS──▶ badge.solana-htn.com ──WSS──▶ HTN OS on the badge
(any language) apps/badge-service firmware/
Production base URL: https://badge.solana-htn.com. Local development:
http://localhost:3100 (bun run --filter '@htn/badge-service' dev).
This document is the contract. packages/shared/src/htnos.ts holds the same
constants and zod schemas for TypeScript; firmware/main/htnos.h mirrors the
constants in C.
1. Ids, keys, and who can do what
| Thing | Where it comes from | Who knows it |
|---|---|---|
| HTN-ID | Minted by the service the first time a badge connects; 5 chars from 23456789abcdefghjkmnpqrstuvwxyz. Shown on the badge home screen. |
Public. Anyone can read a badge's online status by id. |
| Device token | Minted with the id; stored in the badge's NVS. Proves a socket really is that badge. | Badge and service only. Never leaves the wire protocol. |
| App key | Set by the owner on the badge (Settings → App key → Generate or Type). 4–32 printable characters. | The owner shares it with apps they trust. Every command must carry it. |
Commands are refused with 403 key_not_set until the owner sets a key. The
service checks the key (it stores only a SHA-256 hash) and the badge checks it
again on every message, so a compromised service still cannot drive a badge
whose key it does not know. Rotate the key on the badge at any time; old apps
stop working immediately.
Supply the key as the X-Badge-Key header or the key query parameter.
2. Public API
All responses are JSON. Errors use { "error": "<code>", "detail"?: "<text>" }.
| Status | error |
Meaning |
|---|---|---|
| 400 | invalid_json, invalid_request |
Body did not parse or did not match the schema. |
| 400 | image_invalid |
Not a decodable PNG/JPEG. |
| 403 | bad_key |
Wrong app key. |
| 403 | key_not_set |
The owner has not set an app key yet. |
| 404 | badge_not_found |
No badge with that HTN-ID has ever registered. |
| 404 | app_not_found |
No such app in the store. |
| 409 | badge_offline |
Registered, but not connected right now. |
| 413 | image_too_large |
Upload exceeds 512 KiB. |
| 429 | rate_limited |
More than 20 commands/s (burst 40) or 400 KB/s to one badge. |
| 504 | badge_timeout |
The badge did not answer within 5 s. |
Colours
Wherever a colour is accepted it is either "#rrggbb", "#rgb", or [r, g, b]
(0–255). The service converts to RGB565 for the wire.
GET /v1/health
{ "ok": true, "badgesRegistered": 12, "badgesOnline": 3, "apps": 4, "publicUrl": "https://badge.solana-htn.com" }
GET /v1/badges/:id
Public; no key.
{
"badgeId": "xb2b9",
"online": true,
"hasKey": true,
"fw": "0.1.0",
"registeredAt": "2026-09-19T14:02:11.000Z",
"lastSeenAt": "2026-09-19T15:40:02.000Z",
"mode": "canvas"
}
mode is canvas while a remote app owns the screen, menu while the owner is
in the HTN OS menus, null when offline.
Screen
The screen is 320×240, origin top-left, landscape. There is no framebuffer on
the badge: each command draws directly, so draw in the order you want to see.
The first screen command switches the badge into canvas mode (blank
screen, button events flowing); the owner holds Home for one second to
leave canvas mode and return to the menu, which emits a mode event.
POST /v1/badges/:id/clear — { "color"?: Color } (default black) → { "ok": true }
POST /v1/badges/:id/text
{ "text": "HELLO\nWORLD", "x": 8, "y": 8, "size": 3, "color": "#14f195", "background": "#000", "clear": true }
size picks a bitmap font: 1 = 6×12 px per glyph, 2 = 8×16, 3 = 12×24,
4 = 16×32 (so 53, 40, 26, or 20 characters fit on a line). \n starts a new
line; there is no word wrap. text is at most 256 characters of printable ASCII. clear: true
fills the whole screen with background first. → { "ok": true }
POST /v1/badges/:id/rect — { "x", "y", "w", "h", "color" } filled → { "ok": true }
POST /v1/badges/:id/image
Either a raw body with Content-Type: image/png or image/jpeg and options as
query parameters (?x=0&y=0&fit=contain), or JSON:
{ "image": "<base64 png or jpeg>", "x": 0, "y": 0, "fit": "contain" }
fit: "contain" (default) scales the image to fit 320×240 keeping its aspect
ratio and centres it; fit: "none" draws it 1:1 at x,y and crops. Uploads
are capped at 512 KiB. The service decodes, converts to RGB565, and streams
the pixels to the badge in 8-row chunks; a full-screen image is about 150 KB on
the wire and takes roughly a second. → { "ok": true, "width": 320, "height": 240 }
LEDs
POST /v1/badges/:id/leds
{ "leds": ["#ff0000", null, null, null, null, "#0000ff"] }
or { "all": "#101010" }. Six entries in this order looking at the front:
upper-left, upper-right, middle-right, bottom-right, bottom-left, middle-left.
null leaves that LED as it is. The firmware caps every channel at 160/255 so
six white LEDs cannot brown out the AA supply. → { "ok": true }
Buttons
GET /v1/badges/:id/buttons → current state
{ "buttons": { "a": false, "b": false, "home": false, "down": false, "left": false, "right": false, "up": true, "aux1": false, "start": false } }
aux1 is the maintained side switch, so it can be true at boot. Live
presses arrive as button events (see §3) while the badge is in canvas mode.
Accelerometer
GET /v1/badges/:id/accel → { "x": -12, "y": 4, "z": 1002 } in milli-g.
POST /v1/badges/:id/accel/stream — { "hz": 10 } (0 stops, max 20) starts
periodic accel events. Streaming stops automatically when the badge leaves
canvas mode or reconnects.
NFC
POST /v1/badges/:id/nfc — { "timeoutMs"?: 3000 } (max 10 000). Powers the
MFRC522 up, looks for an ISO 14443A tag until the timeout, powers it down.
→ { "uid": "04a1b2c3d4e5f6" } or { "uid": null }.
System
GET /v1/badges/:id/info → { "fw": "0.1.0", "ip": "10.0.0.7", "rssi": -51, "heap": 143120, "uptimeSeconds": 812, "mode": "canvas" }
POST /v1/badges/:id/home → leaves canvas mode and returns the badge to its
menu. → { "ok": true }
Events
GET /v1/badges/:id/events — Server-Sent Events. Needs the key. Each message
has event: set to the event name and data: as JSON:
event: button
data: {"event":"button","badgeId":"xb2b9","button":"a","pressed":true,"at":"2026-09-19T15:40:02.120Z"}
event: accel
data: {"event":"accel","badgeId":"xb2b9","x":-12,"y":4,"z":1002,"at":"..."}
event: mode
data: {"event":"mode","badgeId":"xb2b9","mode":"menu","at":"..."}
event: offline
data: {"event":"offline","badgeId":"xb2b9","at":"..."}
event: online
data: {"event":"online","badgeId":"xb2b9","at":"..."}
event: ping
data: {"event":"ping","at":"..."}
A ping is sent as soon as the stream opens and every 25 s after that. The
stream is not replayed; reconnect and re-read state if you drop.
App WebSocket
GET /v1/badges/:id/ws?key=... upgrades to a WebSocket that carries commands
one way and replies plus events the other, so a game loop needs one socket.
Send (one JSON object per text frame):
{ "cmd": "text", "id": "t1", "text": "PONG", "x": 100, "y": 100, "size": 4 }
{ "cmd": "leds", "id": "l1", "body": { "all": "#220022" } }
{ "cmd": "accel", "id": "a1" }
{ "cmd": "buttons" }
{ "cmd": "image", "image": "<base64>", "fit": "contain" }
{ "cmd": "accelStream", "hz": 10 }
{ "cmd": "nfc", "timeoutMs": 2000 }
{ "cmd": "clear", "color": "#000" }
{ "cmd": "rect", "x": 0, "y": 0, "w": 10, "h": 10, "color": "#fff" }
{ "cmd": "info" }
{ "cmd": "home" }
cmd is the REST endpoint name (accelStream for accel/stream); the other
fields are that endpoint's body (leds wraps its body under body because it
is a union). id is optional and echoed back.
Receive:
{ "type": "reply", "id": "a1", "data": { "x": -12, "y": 4, "z": 1002 } }
{ "type": "error", "id": "t1", "error": "badge_offline" }
{ "type": "event", "data": { "event": "button", "badgeId": "xb2b9", "button": "a", "pressed": true, "at": "..." } }
The socket closes with code 4403 on a bad key, 4404 for an unknown badge.
Rate limits are the same as REST.
App store
GET /v1/apps → { "apps": [ { "appId": "k3j9d2xq", "name": "Badge Pong", "description": "...", "url": "https://...", "author": "...", "sourceUrl": null, "kind": "badge-to-badge", "createdAt": "..." } ] }
newest first.
POST /v1/apps
{ "name": "Badge Pong", "description": "Two badges, one ball. Tilt to move.", "url": "https://pong.example.com", "author": "Ada", "sourceUrl": "https://github.com/ada/badge-pong", "kind": "badge-to-badge" }
kind is server (one badge talks to your server), badge-to-badge, or
tool. Limited to 10 submissions per minute per IP. → { "app": { ... } }
DELETE /v1/apps/:appId with Authorization: Bearer <ADMIN_TOKEN> removes a
listing; 404 app_not_found if it is gone, 403 forbidden without the token,
404 not_found when the service has no ADMIN_TOKEN configured.
3. Examples
curl
BASE=https://badge.solana-htn.com; ID=xb2b9; KEY=hunter2
curl -s $BASE/v1/badges/$ID
curl -s -X POST $BASE/v1/badges/$ID/text -H "X-Badge-Key: $KEY" -H 'content-type: application/json' \
-d '{"text":"HELLO","size":4,"clear":true,"color":"#9945ff"}'
curl -s -X POST $BASE/v1/badges/$ID/leds -H "X-Badge-Key: $KEY" -H 'content-type: application/json' \
-d '{"all":"#001030"}'
curl -s -X POST "$BASE/v1/badges/$ID/image?fit=contain" -H "X-Badge-Key: $KEY" \
-H 'content-type: image/png' --data-binary @logo.png
curl -s $BASE/v1/badges/$ID/accel -H "X-Badge-Key: $KEY"
curl -N "$BASE/v1/badges/$ID/events?key=$KEY"
Badge ↔ server: a reaction-time game in TypeScript
One badge, your server. Show a prompt, wait for A, score the delay.
const BASE = 'wss://badge.solana-htn.com';
const badge = 'xb2b9';
const key = 'hunter2';
const ws = new WebSocket(`${BASE}/v1/badges/${badge}/ws?key=${key}`);
const send = (msg: object) => ws.send(JSON.stringify(msg));
ws.onopen = async () => {
send({ cmd: 'text', text: 'WAIT...', size: 4, x: 40, y: 100, clear: true });
await new Promise((r) => setTimeout(r, 1000 + Math.random() * 3000));
send({ cmd: 'clear', color: '#14f195' });
send({ cmd: 'text', text: 'PRESS A!', size: 4, x: 32, y: 100, background: '#14f195', color: '#000' });
send({ cmd: 'leds', body: { all: '#00ff00' } });
started = Date.now();
};
let started = 0;
ws.onmessage = ({ data }) => {
const msg = JSON.parse(String(data));
if (msg.type === 'event' && msg.data.event === 'button' && msg.data.button === 'a' && msg.data.pressed) {
const ms = Date.now() - started;
send({ cmd: 'text', text: `${ms} ms`, size: 4, x: 60, y: 100, clear: true });
send({ cmd: 'leds', body: { all: '#000' } });
}
};
Badge ↔ badge: tilt pong in Python
Two badges, one server. Each badge tilts to move its paddle; the ball is drawn
on both screens. Everything is REST + one SSE stream per badge, so it runs
anywhere requests does.
import json, threading, time, requests
BASE = "https://badge.solana-htn.com"
PLAYERS = {"left": ("xb2b9", "hunter2"), "right": ("m4kq7", "secret9")}
W, H = 320, 240
def post(badge, key, path, body):
requests.post(f"{BASE}/v1/badges/{badge}/{path}", json=body, headers={"X-Badge-Key": key}, timeout=5)
tilt = {"left": 0, "right": 0}
def listen(side):
badge, key = PLAYERS[side]
post(badge, key, "accel/stream", {"hz": 10})
with requests.get(f"{BASE}/v1/badges/{badge}/events", headers={"X-Badge-Key": key}, stream=True) as r:
for line in r.iter_lines():
if line.startswith(b"data:"):
ev = json.loads(line[5:])
if ev["event"] == "accel":
tilt[side] = ev["y"] # milli-g; sign picks the direction
for side in PLAYERS:
threading.Thread(target=listen, args=(side,), daemon=True).start()
pad = {"left": H // 2, "right": H // 2}
ball = [W // 2, H // 2]; vel = [4, 3]
for badge, key in PLAYERS.values():
post(badge, key, "clear", {"color": "#000"})
while True:
for side in pad:
pad[side] = max(20, min(H - 20, pad[side] + tilt[side] // 100))
ball[0] += vel[0]; ball[1] += vel[1]
if not 0 < ball[1] < H: vel[1] = -vel[1]
if ball[0] < 8 and abs(ball[1] - pad["left"]) < 24: vel[0] = -vel[0]
if ball[0] > W - 8 and abs(ball[1] - pad["right"]) < 24: vel[0] = -vel[0]
if not 0 < ball[0] < W: ball = [W // 2, H // 2]
for side, (badge, key) in PLAYERS.items():
post(badge, key, "clear", {"color": "#000"})
post(badge, key, "rect", {"x": 0 if side == "left" else W - 6, "y": pad[side] - 20, "w": 6, "h": 40, "color": "#9945ff"})
post(badge, key, "rect", {"x": ball[0] - 4, "y": ball[1] - 4, "w": 8, "h": 8, "color": "#14f195"})
time.sleep(0.1)
Ten frames a second with three commands each stays under the 20 commands/s budget. For smoother motion, redraw only what moved (draw the old position in black, then the new one) instead of clearing the whole screen.
Rock, paper, scissors from the browser
https://solana-htn.com/badge/rps is a complete sample app that runs entirely
in the page: it opens the app WebSocket for your badge, draws the prompt,
turns Left / Up / Right presses into rock / paper / scissors, and shows the
result on the screen and LEDs. Enter a second badge to play badge ↔ badge.
The source is apps/pwa/src/routes/badge/rps/ and apps/pwa/src/lib/rps*.ts.
Show a picture from the browser
const file = document.querySelector('input[type=file]').files[0];
await fetch(`https://badge.solana-htn.com/v1/badges/xb2b9/image?fit=contain`, {
method: 'POST',
headers: { 'X-Badge-Key': 'hunter2', 'content-type': file.type },
body: file,
});
4. On the badge
| Key | In menus | In canvas mode |
|---|---|---|
| Up / Down / Left / Right | Move | button event |
| A | Select / type | button event |
| B | Back / backspace | button event |
| Home | Open or close the menu | short press: button event; hold 1 s: leave canvas |
| Start | Confirm (keyboard OK) | button event |
| Aux1 (side switch) | — | button event on change |
Screens: Home (big HTN-ID, Wi-Fi, link and key status), Menu → Wi-Fi
(scan, pick, type password, saved to flash), App key (generate or type), Server
(the service URL, default wss://badge.solana-htn.com/v1/device/ws), Canvas,
LED test, About. The on-screen keyboard has lower, UPPER, and symbol pages;
Start confirms, Home cancels.
If the badge shows NO ID, it has not reached the service yet: check Wi-Fi. The link reconnects by itself with back-off (2 s … 30 s).
5. Wire protocol (badge ↔ service)
For firmware hackers and anyone running their own service. One WebSocket from
the badge to GET /v1/device/ws. Text frames are single JSON objects with a
one-letter type t; binary frames are screen blits. Text frames from the
service never exceed 1024 bytes; binary frames never exceed 8192 bytes.
Badge → service:
| Frame | Meaning |
|---|---|
{"t":"hello","v":1,"mac":"aabbccddeeff","fw":"0.1.0","id":"xb2b9","tok":"…","key":"…"} |
First frame after connect. id/tok are omitted on the first ever boot; key is included when the owner has one so the service learns it after a database reset. |
{"t":"key","k":"hunter2"} |
Owner set or changed the app key. "" clears it. |
{"t":"mode","m":"menu"} |
The badge entered or left canvas mode. |
{"t":"btn","n":"a","p":1} |
Button press (p=1) or release (0); canvas mode only. |
{"t":"accel","x":-12,"y":4,"z":1002} |
A streamed sample, milli-g. |
{"t":"r","i":7, …} |
Reply to the command that carried "i":7. Fields: btn → "b":{…}, accel → "x","y","z", nfc → `"uid":"…" |
Service → badge (every command carries the app key k, which the badge
compares in constant time and silently drops on mismatch):
| Frame | Meaning |
|---|---|
{"t":"welcome","id":"xb2b9","tok":"…"} |
Registration accepted. tok is present only when newly minted; the badge stores both in NVS. |
{"t":"err","c":"bad_token"} |
The id/token pair is unknown; the badge forgets both and re-hellos to register again. bad_hello closes the socket; replaced means a second socket for the same badge took over. |
{"t":"clear","k":…,"c":0} |
Fill the screen with RGB565 c. |
{"t":"text","k":…,"x":8,"y":8,"s":"HI\nTHERE","c":65535,"b":0,"z":2,"cl":1} |
Draw text; z is the scale; cl:1 clears to b first. |
{"t":"rect","k":…,"x":0,"y":0,"w":10,"h":10,"c":2016} |
Filled rectangle. |
{"t":"leds","k":…,"l":[[255,0,0],null,null,null,null,[0,0,255]]} |
Set LEDs; null keeps the current colour. |
{"t":"btn","k":…,"i":7} |
Ask for the button snapshot. |
{"t":"accel","k":…,"i":8} |
Ask for one accelerometer sample. |
{"t":"accel_stream","k":…,"hz":10} |
Stream samples; 0 stops. |
{"t":"nfc","k":…,"i":9,"ms":3000} |
Scan for a tag for ms milliseconds. |
{"t":"info","k":…,"i":10} |
Ask for system info. |
{"t":"home","k":…} |
Leave canvas mode. |
Binary blit frame, all integers big-endian:
byte 0 0x01
byte 1 L, key length (4..32)
bytes 2..2+L the app key, ASCII
u16 x, u16 y, u16 w, u16 h
w*h*2 bytes RGB565 pixels, big-endian — the ST7789's native order, DMA-able as-is
Every screen command switches the badge to canvas mode first. The badge
replies to welcome by sending its current mode and (if set) key.
WebSocket ping/pong keeps the connection alive every 20 s; the service marks a
badge offline when its socket closes.
6. Running your own service
bun run --filter '@htn/badge-service' dev starts it on port 3100 with a local
SQLite file. Point a badge at it from Settings → Server with
ws://<your-laptop-ip>:3100/v1/device/ws (plain ws:// is fine on a LAN; the
production URL must be wss://).
| Variable | Default | Purpose |
|---|---|---|
PORT |
3100 |
Listen port. |
DATABASE_PATH |
./data/badge.db |
SQLite file; :memory: for tests. |
CORS_ORIGINS |
http://localhost:5173,http://127.0.0.1:5173 |
Comma-separated browser origins allowed to call the API. |
PUBLIC_URL |
http://localhost:3100 |
Reported in /v1/health and used in docs links. |
ADMIN_TOKEN |
unset | Enables DELETE /v1/apps/:appId. |
NODE_ENV |
development |
production disables verbose logging. |
The service is single-process by design: badge sockets, reply correlation, and event fan-out are in memory, and SQLite is replicated by Litestream in production. Do not scale it past one instance.