One JSON object per line (NDJSON). The same messages travel over:
- USB serial: CoreS3 native USB, any baud,
\n-terminated lines; - Wi-Fi: WebSocket
ws://stackchan.local:81(or the IP shown on the face), one text frame per message.
Several clients can be connected at once (USB + up to 4 WebSocket clients). Each one gets its own replies and its own state stream.
| Kind | Shape |
|---|---|
| Request | {"id": 7, "cmd": "servo.move", "args": {"id": 1, "deg": 10}} (id optional, echoed back) |
| Reply | {"id": 7, "ok": true, "result": {...}}, or {"id": 7, "ok": false, "error": "no reply"} |
| Event | {"ev": "hello" | "state" | "fault", ...} (no id) |
| Binary frame | WebSocket only. First byte is the type: J camera JPEG (after camera.start), A microphone audio (after mic.start), S speaker audio (client → robot), U dance file upload (client → robot, see dance.*). Audio: 16 kHz mono int16 little endian. |
- Position
raw: 0..4095 over 360°.deg: (raw − 2048) × 360 / 4096, so 0° is the middle of travel. speed: steps/s (0 = maximum).acc: ×100 steps/s² (0 = maximum).load: 0.1 % of maximum torque, signed.volt: V.temp: °C.
| cmd | args | result |
|---|---|---|
ping |
– | pong (ms since boot) |
hello, sys.info |
– | fw, mac, uptime_s, heap, wifi{connected,ssid,ip,rssi}, bus{ok,port,rx,tx,baud,errors}, face |
sys.reboot |
– | restarts (no reply) |
bus.detect |
– | searches Grove ports C, A, B, then the body's internal head-servo bus (GPIO6/7) (both TX/RX orders, 1 Mbps), saves the result |
bus.probe |
– | probes: [{port, rx, tx, baud, bytes}]: raw bytes heard after a broadcast ping on every pin pair at 1M / 500k / 115200. Garbled bytes usually mean several servos share an id. |
bus.scan |
from, to (0..253) |
ids, checksum_errors (> 0 usually means two servos share an id) |
servo.read |
id |
pos, deg, speed, load, volt, temp, moving, status (if nonzero), limits_deg |
servo.move |
id, deg or raw, speed (600), acc (20) |
raw, deg, clamped |
servo.sync_move |
targets: [{id, deg|raw}] (≤ 8), speed, acc |
count. One packet, servos start together, no per-servo reply. |
servo.torque |
id, on |
– |
servo.torque_limit |
id, permille (0..1000) |
– (RAM: resets at power-off) |
servo.set_id |
id, new_id |
– (refused if new_id already answers) |
servo.limits |
id, optional lo_deg + hi_deg |
lo_deg, hi_deg (soft limits, saved in NVS; moves are clamped to them) |
head.read |
– | servos: [{id, raw, deg}] (StackChan body head servos: 1 = yaw, 2 = pitch; SCS0009, raw 0..1023 over 300°) |
head.move |
id, deg or raw, ms (travel time, 400) |
raw |
head.torque |
id, on |
– |
stream.start |
ids (≤ 16), hz (1..100, default 20) |
hz, count |
stream.stop |
– | – |
face.set |
expression (see face.expressions), style, gaze: [v, h] (−1..1), mouth (0..1), blink, say (caption, "" clears), ms (caption time, 0 = keep), idle (daydream on its own after 15 s without input; default true) |
expression, style, idle (daydreaming now) |
face.expressions |
– | expressions: neutral happy excited love shy wink proud smug sad cry angry doubt curious surprised scared dizzy sleepy sigh bored listening thinking daydream yawn doze. Expressions morph into each other; the eyes breathe, blink and glance on their own. |
face.styles |
– | styles (names) |
face.snap |
– | jpeg (base64, what the screen shows), bytes |
camera.snap |
quality (10..95) |
jpeg (base64, 320 × 240), bytes, seq |
camera.start |
fps (1..15, default 5), quality |
fps, quality. WebSocket only: frames arrive as binary messages. |
camera.stop |
– | – |
camera.config |
quality, hmirror, vflip |
quality |
mic.start |
– | rate, chunk (samples). WebSocket only: A frames every 40 ms. Fails while the speaker plays (half duplex). |
mic.stop |
– | – |
speaker.status |
– | playing, mic |
speaker.stop |
– | – (drops queued audio) |
speaker.volume |
volume (0..255, 180) |
– |
dance.list |
– | dances: [{name, bytes, seconds}], free (bytes) |
dance.play |
name (dance_1) |
– (it eases into the first pose, then plays music, servos and faces on its own clock; holding the screen 1 s does the same for dance_1, a tap stops it) |
dance.stop |
– | – (music off, servos ease to the neutral pose) |
dance.status |
– | playing, name, t, seconds |
dance.delete |
name |
– |
wifi.scan |
– | networks: [{ssid, rssi, open}], strongest first |
wifi.set |
ssid, pass |
– (USB only; up to 5 networks in NVS; passwords are never sent back) |
wifi.forget |
ssid |
– |
wifi.list |
– | saved (SSIDs only) |
wifi.status |
– | same as sys.info |
hello: sent on USB at boot and to each WebSocket client when it connects; same fields assys.info(pluscamera,face_style).state: afterstream.start, every 1/hz s:{"ev":"state","t":12345,"servos":[{"id":1,"pos":2050,"deg":0.2,"speed":0,"load":-12,"volt":12.1,"temp":31,"moving":false}]}. A servo that does not answer is left out ofservos.fault:{"ev":"fault","id":3,"fault":"no reply"}when a streamed servo stops answering,"back"when it answers again.upload:{"ev":"upload","name":"dance_1","got":12288,"total":320352,"done":false}after eachUframe (erroron failure). AUframe isu8 n, name[n], u32 offset, u32 total, bytes(≤ 15 KB); chunks go in order from offset 0. File layout:firmware/stackchan_agent/dance.h.
- Every move is clamped to the servo's soft limits (
servo.limits). - When a client disconnects its state and camera streams stop. The servos keep their last goal: nothing moves on its own.
- The firmware never turns torque on by itself; only
servo.torqueandservo.move(a new goal) do.