Push your own sensor data into the HUD over a local HTTP endpoint. Everything you send is drawn on the right side of the HUD and burned into the recorded MP4 (the panels are canvas widgets, same as the weather gauges).
Three endpoints:
POST /sensors— scalar readouts (a labelled value column, top‑right).POST /series— numeric time series, drawn as a sparkline (mid‑right).POST /text— a free‑text caption with a typewriter effect (bottom‑center).
Settings → API Service → Enable API Service. Then configure:
| Setting | Meaning |
|---|---|
| Bind Host | Dropdown listing 127.0.0.1 (this device only), 0.0.0.0 (all interfaces/LAN), and detected LAN IPs (e.g., 192.168.1.20). Non-loopback bindings require a token. |
| Port | TCP port (default 1337). |
| Token | Bearer token, auto‑generated; Regenerate makes a new one. Required when binding to non-loopback addresses. |
Enabling or disabling the service, and regenerating the token, show a confirmation modal first. The server starts when enabled, restarts when port or bind host changes, and stops when the app is locked or closed. Regenerate token persists and restarts the service immediately (no Save needed).
iOS note: The Sensor API is foreground-only. When the app is backgrounded, the server stops (no drain from a listening socket). When the app returns to foreground, the server auto-restarts if enabled, without manual re-enabling. On first LAN access, iOS prompts for the Local Network permission (NSLocalNetworkUsageDescription).
Send the token as a bearer header:
Authorization: Bearer <token>
- Non-loopback binding (0.0.0.0, LAN IPs) requires a non‑empty token — every request is checked. The server refuses to start on a network address without a token.
- Loopback binding (127.0.0.1): if the token is empty the check is skipped; otherwise it is required.
# Example: localhost on this device
curl -X POST http://127.0.0.1:1337/sensors \
-H "Authorization: Bearer <token>" \
-d '{"items":[
{"label":"CO2","value":"812","unit":"ppm"},
{"label":"HR","value":"78","unit":"bpm"}
]}'
# Example: LAN IP (e.g., device on same WiFi)
# Pick the Bind Host IP from Settings, e.g., 192.168.1.20:
curl -X POST http://192.168.1.20:1337/sensors \
-H "Authorization: Bearer <token>" \
-d '{"items":[{"label":"CO2","value":"812","unit":"ppm"}]}'- Body:
{ "items": [ { "label": string, "value": string, "unit"?: string } ] } - Rendered as
LABEL … VALUE unitrows, right‑aligned, top‑right. - Latest push replaces the previous set. Rows dim after ~10 s without an update.
# Example: localhost
curl -X POST http://127.0.0.1:1337/series \
-H "Authorization: Bearer <token>" \
-d '{"label":"ALT","value":12345,"unit":"m"}'
# Example: LAN IP
curl -X POST http://192.168.1.20:1337/series \
-H "Authorization: Bearer <token>" \
-d '{"label":"ALT","value":12345,"unit":"m"}'- Body:
{ "label": string, "value": number, "unit"?: string }— one point per call. - The app keeps a rolling buffer of the last ~120 points per
labeland draws a mini line chart: x = time (sample order), y = value, auto‑scaled to the buffer's min/max, with a dot on the latest point. Row showsLABEL <line> VALUE unit. - Keep POSTing the current value at your own cadence; the app builds the curve.
# Example: localhost
curl -X POST http://127.0.0.1:1337/text \
-H "Authorization: Bearer <token>" \
-d '{"text":"RS41 · Y0532363","typing":true}'
# Example: LAN IP
curl -X POST http://192.168.1.20:1337/text \
-H "Authorization: Bearer <token>" \
-d '{"text":"RS41 · Y0532363","typing":true}'- Body:
{ "text": string, "typing"?: boolean }(typingdefaults totrue). - Drawn left‑aligned just below the location line; characters reveal at ~25/sec
with a blinking cursor. A new push restarts the reveal. Set
"typing":falseto show it instantly. Text is truncated to 120 chars. - Idle fallback: with no caption in the last ~12 s, the slot shows a decorative
random hex stream (e.g.
0x4F2A 0x9C11 …) so it never looks empty.
curl http://127.0.0.1:1337/healthz
# → 200 {"ok":true,"app":"LazyCamHUD","version":"0.6.1"}No token required — use it to confirm the server is reachable (right IP/port,
firewall) before pushing. A connection error means the API is off; 200 means it
is up; a 401 from a data POST means it is up but the token is wrong.
Every response is JSON:
| Status | Body | When |
|---|---|---|
200 |
{"ok":true,"count":N} (/sensors) · {"ok":true} (/series) |
accepted |
400 |
{"ok":false,"error":"bad json"} / "value not finite" |
malformed body |
401 |
{"ok":false,"error":"unauthorized"} |
missing/wrong token |
413 |
{"ok":false,"error":"too large"} |
body over the limit |
404 |
{"ok":false,"error":"not found"} |
wrong method/path |
Add -i to curl to also see the HTTP status line.
/sensors: ≤ 6 items;label/value/unittruncated to 12 / 10 / 6 chars./series: ≤ 120 points kept per label (older points drop off)./text: caption truncated to 120 chars.- Body ≤ 8 KB. Values are display text only — nothing is executed.
Two zero‑dependency Node scripts (Node 18+) are bundled to feed the API with realistic radiosonde telemetry — handy for demos and for testing the panels.
- Run the app (
npm run tauri dev), then Settings → Sensor API → Enable and copy the token. - Feed it — either the synthetic flight or a real log:
# synthetic flight (no data file needed)
node scripts/mock-sonde.mjs <token>
# replay a real auto_rx RS41 log (example, faster than real time + looping)
SONDE_SPEED=20 SONDE_LOOP=1 \
node scripts/replay-sonde-log.mjs scripts/20260708-115249_Y0532363_RS41_403000_sonde.log <token><token> is the Sensor API token from Settings. Pass the log path as the first
arg (absolute, or relative to where you run the command). Add SONDE_URL=... to
target another machine/port. Watch the readouts, sparklines and typewriter caption
appear on the HUD (and in the recording).
Generates a flight from scratch: ascent at ~5 m/s → burst at ~32 km → parachute descent (faster up high, slower low), with wind drift and battery drain. No input data needed.
node scripts/mock-sonde.mjs <token> # → http://127.0.0.1:1337
SONDE_URL=http://192.168.1.20:1337 SONDE_INTERVAL=1000 \
node scripts/mock-sonde.mjs <token>Pushes a name caption once (/text → "MOCK SONDE · HANOI"), then each second:
/sensors: LAT, LON, DIST (km from launch), BATT (%)./series: ALT (m), SPD (horizontal m/s).
Replays a real auto_rx
CSV log (e.g. an RS41) row‑by‑row, paced by the rows' own timestamps. A sample log
is bundled at scripts/20260708-115249_Y0532363_RS41_403000_sonde.log.
Expected header:
timestamp,serial,frame,lat,lon,alt,vel_v,vel_h,heading,temp,humidity,pressure,type,freq_mhz,snr,f_error_hz,sats,batt_v,burst_timer,aux_data
node scripts/replay-sonde-log.mjs <logfile> <token> # real time (1 Hz)
SONDE_SPEED=20 node scripts/replay-sonde-log.mjs <logfile> <token> # 20× faster
SONDE_LOOP=1 node scripts/replay-sonde-log.mjs <logfile> <token> # repeatPushes the sonde name once (/text → <type> · <serial>, e.g. "RS41 · Y0532363"),
then per row:
/sensors: LAT, LON, DIST (from the first row = launch site), TEMP (--until the RS41 sensor boom deploys, i.e. whiletempreads-273), BATT (V), SATS./series: ALT (m), CLIMB (vel_v, m/s).
Env vars: SONDE_TOKEN, SONDE_URL, SONDE_INTERVAL (mock), SONDE_SPEED
(replay time multiplier), SONDE_LOOP=1 (replay).
- The endpoint accepts display text only; there is no code path that executes it.
- Size/count/length are clamped so a device cannot overflow the HUD or memory.
- Non-loopback binding (0.0.0.0, LAN IPs) requires a token — the server refuses to start on a network address without one. Token is compared in constant time. Treat it like a shared password on your network.
- The token lives in
config.jsonalongside other settings (not a secret store). On iOS, the entire sandbox is encrypted at rest if the device has a passcode. - iOS foreground-only: when the app is backgrounded, the listening socket closes, preventing network attacks on a backgrounded instance.
- Local network only: the endpoint is not exposed to the internet; it only listens on local addresses (loopback or LAN IPs). For remote access, use a VPN or local proxy.