Skip to the content.

HTTP control plane

A LAN-scoped HTTP/1.1 server runs on port 80 once Wi-Fi connects. It exposes a small, fixed set of routes for live state, manual override, persistent config, and an embedded operator dashboard. Write routes (PUT, POST) are gated on a configurable bearer token — empty token (default) leaves the LAN open, matching the offline-first stance for Wi-Fi. See Auth for how to enable it; security covers what’s still out of scope.

The wire-format implementation is hand-rolled in crates/stackchan-firmware/src/net/http.rs — the surface is small enough that a hand-rolled request matcher beats pulling a full HTTP framework into the firmware target.

Routes

Method Path Description
GET / Operator dashboard (HTML + JS, embedded in firmware)
GET /health Uptime, firmware version, free heap
GET /state Snapshot JSON: emotion, head pose, battery, Wi-Fi, audio
GET /state/stream Server-Sent Events stream of state changes
POST /emotion Set affect with hold timer
POST /look-at Aim head + eyes with hold timer
POST /reset Clear active emotion / look-at hold
POST /speak Play a baked phrase / chirp through the speaker
POST /volume Set output volume (0–100); persisted to SD
POST /mute Mute / un-mute output stage; persisted to SD
GET /settings Persisted config (PSK + token redacted)
PUT /settings Replace persisted config; atomic SD writeback

Write routes (PUT, all POST) require Authorization: Bearer <token> when auth.token is configured. Reads are always unauthenticated.

Live state

$ curl http://stackchan.local/state
{"emotion":"Neutral","head_pose":{"pan_deg":0.00,"tilt_deg":0.00},
 "head_actual":{"pan_deg":0.10,"tilt_deg":-0.20},
 "battery":{"percent":78,"voltage_mv":3920},
 "wifi":{"connected":true,"ip":"192.168.1.42"},
 "audio":{"volume_pct":50,"muted":false}}

GET /state/stream opens a Server-Sent Events stream. The server emits an initial event on connect, then one event per change. Idle connections receive : heartbeat SSE comment lines every 15 s so proxies and NAT idle timers don’t close the connection.

$ curl -N http://stackchan.local/state/stream
data: {"emotion":"Neutral", ...}

data: {"emotion":"Happy", ...}

: heartbeat

Producer-side throttling caps events at ~10 Hz even when the underlying render loop ticks at 30 Hz.

The HTTP layer accepts on a pool of worker tasks, so a long-lived SSE stream doesn’t block other requests on the same port.

Manual control

POST /emotion, POST /look-at, and POST /reset write into the modifier pipeline through the RemoteCommandModifier in stackchan-core. Each command takes effect on the next render tick.

POST /emotion

$ curl -X POST http://stackchan.local/emotion \
       -H 'Content-Type: application/json' \
       -d '{"emotion":"happy","hold_ms":30000}'
Field Type Required Notes
emotion string yes neutral / happy / sad / sleepy / surprised / angry
hold_ms u32 no Default 30 000. 0 is fire-and-forget.

The hold blocks autonomous emotion drivers (touch, IR, ambient, battery, EmotionCycle) for hold_ms. Source recorded as OverrideSource::Remote.

POST /look-at

$ curl -X POST http://stackchan.local/look-at \
       -H 'Content-Type: application/json' \
       -d '{"pan_deg":12.0,"tilt_deg":-3.0,"hold_ms":30000}'
Field Type Required Notes
pan_deg f32 yes Same coordinate system as motor.head_pose
tilt_deg f32 yes
hold_ms u32 no Default 30 000.

The handler writes mind.attention = Attention::Tracking { target }. HeadFromAttention and GazeFromAttention translate that into motor + eye motion. While the hold is active, fresh tracking observations from the camera don’t stomp the operator’s target.

POST /reset

$ curl -X POST http://stackchan.local/reset

Empty body. Clears any active emotion or look-at hold; autonomous behaviours resume on the next render tick.

POST /speak

$ curl -X POST http://stackchan.local/speak \
       -H 'Content-Type: application/json' \
       -d '{"phrase":"wake_chirp"}'
Field Type Required Notes
phrase string yes Catalog entry — see vocabulary below
locale string no "en" (default) / "ja" — only meaningful for verbal phrases

Phrase vocabulary (matches [stackchan_core::voice::PhraseId]):

Wire string Kind Notes
wake_chirp SFX 100 ms / 1 kHz
pickup_chirp SFX Two-tone upward sweep
startle_chirp SFX Sharp 4 kHz tone
low_battery_chirp SFX Two-pulse 2 kHz alert
camera_mode_entered_chirp SFX Upward “doot-DEE”
camera_mode_exited_chirp SFX Downward “DEE-doot”
greeting Verbal phrase en + ja PCM assets
acknowledge_name Verbal phrase en + ja PCM assets
battery_low Verbal phrase en + ja PCM assets

Fire-and-forget — the request returns 204 No Content once the utterance is queued; playback completes asynchronously. Operator- driven calls land at Priority::Normal. Modifier-internal call sites (low-battery alert, etc.) use elevated priority via the firmware’s audio::try_dispatch_utterance directly and can preempt or evict an in-flight operator request.

POST /speak is gated by auth when a token is configured.

POST /volume

$ curl -X POST http://stackchan.local/volume \
       -H 'Content-Type: application/json' \
       -d '{"level":75}'
Field Type Required Notes
level u8 yes Output volume as a percentile, 0..=100.

Persistent — the new value is written atomically to /sd/STACKCHAN.RON (same writeback path as PUT /settings) and mirrored into the live audio.volume_pct field surfaced on GET /state. The firmware then signals the audio task, which calls Aw88298::set_volume_db with the linear-in-dB mapping (0% → -36 dB, 100% → 0 dB). Persist-then-apply ordering: a failed SD write leaves the amp at its current level instead of partially applying.

POST /mute

$ curl -X POST http://stackchan.local/mute \
       -H 'Content-Type: application/json' \
       -d '{"muted":true}'
Field Type Required Notes
muted bool yes true = mute output stage, false = un-mute.

Persistent. Mute is independent of volume so unmuting restores the prior level — volume = 0 is “audible but very quiet”; muted = true is the actual-silence path.

Persistent config

The boot config lives at /sd/STACKCHAN.RON in the schema described by stackchan-net. The HTTP control plane round-trips it as JSON.

GET /settings

$ curl http://stackchan.local/settings
{"wifi":{"ssid":"my-net","psk":"***","country":"US"},
 "mdns":{"hostname":"stackchan"},
 "time":{"tz":"UTC","sntp_servers":["pool.ntp.org"]},
 "auth":{"token":"***"},
 "audio":{"volume_pct":50,"muted":false}}

wifi.psk and a non-empty auth.token are redacted to ***. On PUT /settings, the server treats either sentinel as “keep current value” rather than overwriting with the literal placeholder — so a GET → mutate hostname → PUT round trip preserves the secrets. An empty "" value still means “clear” (open AP / auth disabled); only the literal "***" triggers the preserve substitution.

PUT /settings

$ curl -X PUT http://stackchan.local/settings \
       -H 'Content-Type: application/json' \
       -H 'Authorization: Bearer s3cret' \
       -d '{"wifi":{"ssid":"new-net","psk":"realkey","country":"US"},
            "mdns":{"hostname":"stackchan"},
            "time":{"tz":"UTC","sntp_servers":["pool.ntp.org"]},
            "auth":{"token":"s3cret"},
            "audio":{"volume_pct":50,"muted":false}}'
{"reboot_required":true}

To disable auth on a device that previously had it enabled, send auth.token = "" (the explicit empty string, not the *** sentinel) and drop the Authorization header on the PUT itself.

Operators using curl who only want to update one field can echo the rest of the body back from GET /settings unchanged — the *** sentinels for wifi.psk and auth.token will be merged against the persisted values rather than clobbering them.

Full-replace. The server validates the body via stackchan_net::validate (rejects empty SSID, invalid country code, invalid hostname, empty SNTP list) and then writes back atomically: the new config goes to /sd/STACKCHAN.NEW, gets copied onto /sd/STACKCHAN.RON, and the staging file is removed. Mid-write power loss leaves the old file intact.

The firmware does not tear down Wi-Fi on save — that would drop the operator’s HTTP session if the SSID changed. Reboot to apply.

Status codes

Code Where it shows up
200 GET responses, dashboard, successful PUT
204 POST /emotion / /look-at / /reset / /volume / /mute on success
400 Malformed JSON, missing required fields, unknown field, invalid emotion / phrase / locale, volume.level > 100, validation failure on PUT /settings
401 Write route called without a valid bearer token (when auth is enabled)
404 Path not in the matcher
405 Method not allowed
413 Request body exceeds MAX_BODY_BYTES (1024)
431 Headers exceed REQUEST_BUF_BYTES before \r\n\r\n is reached
500 SD write failed during PUT /settings
503 PUT /settings with no SD; GET /settings before the config snapshot is loaded; no free SSE subscriber slot

Error responses have Content-Type: text/plain with a single-line reason — operators triage from defmt boot logs, the HTTP body is just a hint.

Dashboard

GET / returns a self-contained HTML page embedded in the firmware binary via include_bytes!. Vanilla DOM + EventSource + fetch, no framework, no external CDN. The dashboard:

To customise it, edit the HTML in crates/stackchan-firmware/src/net/dashboard.html and re-flash. Embedding via include_bytes! instead of serving from SD is intentional: the dashboard works on a card-less device.

Auth

Write routes (PUT, all POST) accept an optional bearer token. Token is stored in auth.token of the persisted config and read once at boot into a snapshot the HTTP handler consults on every write request. Empty token (default) disables the gate; non-empty token requires Authorization: Bearer <token> and returns 401 on mismatch.

Compare is constant-time across the byte length so a co-located attacker can’t leak the token byte by byte through timing — though on a LAN, network jitter dominates any sub-microsecond difference.

$ curl -X POST http://stackchan.local/emotion
HTTP/1.1 401 Unauthorized
unauthorized

$ curl -X POST http://stackchan.local/emotion \
       -H 'Authorization: Bearer s3cret' \
       -H 'Content-Type: application/json' \
       -d '{"emotion":"happy"}'
HTTP/1.1 204 No Content

To enable auth on a fresh kit:

$ curl -X PUT http://stackchan.local/settings \
       -H 'Content-Type: application/json' \
       -d '{"wifi":{...},"mdns":{...},"time":{...},
            "auth":{"token":"s3cret"}}'
$ # reboot the device — Wi-Fi keeps the boot config until restart

The dashboard at GET / reads the configured token from localStorage and prompts the operator on its first 401. The typed value is persisted to the device on PUT /settings and to localStorage, so a freshly configured browser keeps writing without re-prompting until the device reboots.

Security

What’s covered:

What isn’t:

These are acceptable for a desktop toy on a trusted home LAN and explicitly out of scope for v0.x. If you put Stack-chan on an untrusted network, fence it off.

Worker pool sizing

The HTTP layer spawns a fixed pool of worker tasks (HTTP_WORKER_COUNT in net/http.rs). Each worker holds its own rx/tx buffers and accepts one connection at a time. SSE subscribers occupy a worker for the lifetime of the stream; ordinary GET / POST / PUT requests free the worker on response close.

SSE_MAX_SUBSCRIBERS in net/snapshot.rs caps the concurrent SSE consumers. An SSE connection that arrives when no subscriber slot is free gets 503 stream slots exhausted back. The two constants are coupled — bumping concurrency requires raising both.