Pi ⇄ Teensy UART Protocol — SMCS Kart¶
Status: v1 draft — source of truth. /dev/serial0 ⇄ Teensy Serial2,
115200 8N1. Any change must be made here first, then mirrored in
firmware/kart-core and pi/.
The link carries two interleaved channels:
- ASCII command channel (Pi → Teensy requests, Teensy → Pi
OK/ERR/INFOlines) — human-readable, usable from a terminal, backward compatible with the legacy bring-up firmware. - Binary telemetry channel (Teensy → Pi only) — fixed-rate framed packets.
A receiver distinguishes them by the first byte: binary frames always start
with sync byte 0xF7, which never appears in the ASCII channel (it is not
printable ASCII and kart-core never emits it in text).
1. ASCII command channel¶
One command per line (\n or \r terminated). Every command produces exactly
one terminal line starting with OK or ERR. Asynchronous INFO … lines may
appear at any time and are not replies.
The Pi is advisory: commands that could cause motion are either rejected outside the proper drive state or are requests that additionally require driver confirmation on the wheel.
Command set (kart-core)¶
| Command | Reply | Notes |
|---|---|---|
PING |
OK PONG |
|
STATUS |
OK STATUS k=v … |
Human-readable snapshot (telemetry frames are the machine path). |
ARM_REQ |
OK ARM_REQ pending |
Requests SAFE→ARMED; completes only with driver wheel confirmation. Replaces the legacy ARM <seconds> (which remains in firmware/legacy only). |
DISARM |
OK DISARMED |
Always allowed; forces controlled stop → SAFE. |
SAFE |
OK SAFE |
Alias for DISARM semantics. |
FAULT_CLEAR |
OK / ERR FAULT_ACTIVE |
Clears a latched fault only after its cause is gone and the kart is stopped. |
LED <r> <g> <b> |
OK LED r g b / ERR LED … |
Dashboard baseline color (PWM RGB, each 0–255). Any state. This is now the primary LED color; state-indication changes only flash briefly over it (see below). FAULT still overrides with a continuous red blink. |
LED RAINBOW |
OK LED RAINBOW |
Firmware-run hue sweep (the Teensy animates it — the dash does not stream colors). Becomes the baseline like a solid color; a later LED r g b cancels it. Any state. |
RX / RX? |
OK RX link=… frames=… bad_crc=… age_ms=… rssi1=… rssi2=… lq=… snr=… ch=c0,c1,…,c15 |
CRSF receiver status + all 16 live channels. Read-only, any state. Use it on the bench to confirm which channel each transmitter control lands on, then set kRcCh* in config.h. |
STEER_CAL <enter\|center\|left\|right\|save\|abort> |
OK … |
Forwarded to Steervo (0x102), SAFE only. |
CFG <name> <value> / CFG? |
OK … |
Bench tuning, SAFE only. Implemented keys: axis_thr, axis_brk, axis_steer (USB-host axis indices), ped_released, ped_pressed (pedal raw-value calibration). |
WHEELRAW |
OK WHEELRAW enum=… type=… buttons=0x… a0=… … a15=… |
Dumps the live USB-host wheel state (all axes + button mask + joystick type). Use it to discover the Hori pedal axis indices and ranges through USBHost_t36, then set them with CFG. Read-only, any state. |
I2C |
OK I2C found 0x.. … dac@0x60 |
Scans the MCP4725 I2C bus and lists ACKing addresses. Read-only. Empty = DAC unpowered (key off) or not present. |
DACREAD |
OK DACREAD dacval=…/4095 pd=… settings=0x… |
Reads back the MCP4725 register + power-down bits — verifies the DAC actually stored what was written (catches silent I2C corruption). Needs the DAC powered (key on). |
HALLDIAG [ON\|secs] / HALLDIAG OFF |
OK HALLDIAG … then INFO HALLDIAG win_ms=… filt=… raw=… dropped=… fhz=… rhz=… min_us=… max_us=… lvl=… @10 Hz |
Speedo bring-up: streams the hall/tach line (pin 2 ← ESC pin 18) — filtered vs unfiltered edge counts (dropped = edges the 120 µs glitch filter rejects), per-window inter-edge min/max µs (spacing evenness), and the raw pin level. Auto-stops after secs (default 60). Read-only. |
DACSET <pct> |
OK DACSET …% for 10s |
SAFE only. Holds a fixed throttle DAC voltage for ~10 s for multimeter/scope verification (contactor open → no motion). Auto-clears; refused outside SAFE. |
GEAR <low\|med\|high> |
OK GEAR … |
Resyncs the firmware's open-loop gear model to what the FarDriver app shows (does not pulse the line). Use if the LED gear color drifts from the ESC. |
ESC_READ [n] / ESC_WRITE <hex> |
OK … |
ESC serial passthrough for the research track. ESC_WRITE is bench-mode only (SAFE + explicit BENCH on). (planned; not yet implemented) |
BENCH <on\|off> |
OK BENCH … |
Enables bench-only commands; refused if hall speed ≠ 0 or state ≠ SAFE. (planned; not yet implemented) |
VERSION |
OK VERSION kart-core <semver> proto=1 |
Current: 0.5.0-rc. |
STATUS (rich, used heavily for bring-up diagnostics) reports:
state fault bench wheel thr brk hall hz10 contactor rev speed dac dacok dacfail plaus busrdy vstop thr0 wheelok
— where dac/busrdy/thr0/wheelok/plaus/vstop are the live DRIVE-entry/health gate
bits (1 = satisfied) and dacok/dacfail are cumulative DAC write success/fail
counts (watch the deltas to see I2C health under motor load). speed= now
carries the full shift-ladder rung — reverse · park · low · med · high
— and rev=1 iff in Reverse.
Legacy commands (THROTTLE, BRAKE, CONTACTOR, SPEED, OUTPUT, …) exist
in the legacy firmware for bring-up via tools/kartctl.py; kart-core does
not accept direct actuation from the Pi — pedals and the state machine are
the only motion path.
Operator gestures (wheel)¶
Arming and drive-state changes happen on the wheel, never from the Pi alone
(spec §3.1). The Pi ARM_REQ is advisory; it does not arm.
Button bits below are the Hori-via-USBHost_t36 Xbox-One mapping (not the
Linux-js numbers): paddles = bits 12 (left) / 13 (right). Face buttons
re-confirmed on the bench (July 2026) sit on consecutive bits: A=4, B=5, X=6,
Y=7. So kBtnDrive = bit 6 = the physical X button. Direction (Reverse) is
not a face button — it is the bottom rung of the paddle shift ladder (below),
so nothing reads bit 2 anymore.
The shift paddles drive a single linear ladder — Reverse < Park < Low < Med < High (left paddle = down, right = up, one rung per press):
- Park is neutral: throttle is inhibited entirely. A fresh arm always starts in Park (the ladder is forced there whenever not armed/driving), so no throttle is possible until the driver selects a drive gear.
- Reverse holds the ESC REV line and holds the ESC in Low; throttle is live. Low/Med/High and Reverse all deliver throttle — only Park does not.
- Every rung is freely selectable at any time — there is no standstill
gating. (An earlier build gated Reverse on "vehicle stopped," but on stands
the wheels coast for a long time and "stopped" is derived from hall edges, so
Reverse was effectively unreachable; the ESC's own direction handling is the
backstop.) Pure logic lives in
firmware/kart-core/lib/kartcore/shift_ladder.h.
Reverse and brake are both HELD ESC lines (not pulses). Only the gear-cycle (high-speed) line is a momentary toggle on this ESC. - Reverse (Teensy pin 3 → ESC REV / ESC pin 8) is grounded for as long as the Reverse rung is selected, holding the ESC in reverse (an ~80 ms pulse only flickered it into reverse then back to 1st). FWD↔REV follows the ladder; not-armed/Park resolves to forward, so a disarm always ends forward. - Brake (Teensy pin 4 → ESC Low Brake / ESC pin 21) is grounded while the regen brake is engaged, released otherwise. It engages the ESC's highest regen level in one step, so the pedal is treated as a switch, not a proportional command: past 20 % travel the line is grounded, below it released (also forced on during a controlled stop). Brake overrides throttle.
DRIVE is automatic — there is no separate "go" button. The DSM advances
ARMED → DRIVE on its own once bus_ready and dac_ok hold (bus charged +
contactor closed, and the ESC keyed on) with the throttle released; the driver
just upshifts out of Park to deliver throttle.
| Gesture | Action |
|---|---|
| Both shift paddles (bits 12+13) held + brake pressed + throttle released, 1 s | ARM chord: SAFE → ARMED (precharge → contactor closes; steering energizes). Ladder starts in Park; DRIVE follows automatically when the bus + DAC are ready. |
| B button (bit 5) | Disarm / turn off — controlled stop → SAFE, contactor opens. Works from ARMED or DRIVE. |
| X button (bit 6) — backup only (DRIVE is automatic): in DRIVE → disarm; in FAULT → clear. | not needed to drive |
Pi DISARM/SAFE |
Controlled stop → SAFE (also available any time) |
Pi FAULT_CLEAR |
Clear a latched FAULT (at rest, cause resolved) |
| Right paddle (bit 13), armed/driving | Upshift one rung: Reverse→Park→Low→Med→High (clamps at High). +1 ESC speed mode = 1 gear-cycle pulse. |
| Left paddle (bit 12), armed/driving | Downshift one rung: High→Med→Low→Park→Reverse (clamps at Reverse). −1 ESC speed mode = 2 pulses. Down from Park → Reverse, any time. NB: Park and Reverse both hold the ESC in Low, so the FarDriver app's gear won't change across Low/Park/Reverse — Park cuts throttle, Reverse holds the REV line. |
Operator gestures (RC remote — CRSF on Serial3)¶
A CRSF/ELRS transmitter is the remote driver. RC wins while its link is
up: throttle/steer/shift/arm come from the transmitter, and the Hori wheel only
takes over if the RC link drops. Channel→control map is the transmitter mixer's
job — the defaults below are ELRS AETR; confirm on the bench with RX? and
edit kRcCh* in firmware/kart-core/src/config.h.
| Control (default channel) | Action |
|---|---|
| Left stick U/D (CH3) → throttle | Single stick with a glide band: < 20 % → zero throttle + regen brake engaged; 20–30 % → glide (coast: no brake, no throttle); ≥ 30 % → throttle, remapped so 30 % = 0 % and full stick = 100 % (no lurch leaving the glide band). |
| Left stick L/R (CH4) → steering | Drives STEER_SET. Inert in the KART_TRACTION_ONLY_BENCH=1 build (Steervo away); wired, works at =0. |
| SF bumper (CH10) | Upshift one rung (Reverse→Park→Low→Med→High). |
| SE bumper (CH9) | Downshift one rung (…→Park→Reverse). |
| SA switch (CH5) | Remote startup/shutdown. ON → arm (precharge → contactor → auto-DRIVE, starts in Park); OFF → disarm (controlled stop → SAFE). Arming still requires the kart at rest with the throttle stick down (DSM gate). |
| SD switch (CH8) | Hand control to the Hori wheel+pedals. ON → RC relinquishes; the in-seat wheel/pedals drive (throttle, steer, paddle shift, wheel arm chord). OFF → RC takes control again. This is not a dead-man: the link stays up, so the handover never forces PARK. |
Dead-man (link loss → PARK). CRSF streams continuously, so a lost link is
detected as no valid frame within kRcLinkTimeoutMs (500 ms). On loss, the ladder
snaps to Park (throttle cut, regen brake on) — the contactor stays closed
(this is park, not shutdown); the kart is re-drivable on re-link by upshifting
out of Park, no fault-clear needed. In a pure-RC build (no wheel) a latch keeps
this from tripping kWheelLost while parked. Configure the ELRS receiver's
failsafe to "No Pulses" so a lost link actually stops the stream (not "hold last
position", which would freeze the last throttle and defeat the dead-man). There is
no operator-held dead-man (that was option (b), deferred) — only link-loss.
LED indication: the dashboard owns the baseline color (LED r g b, PWM RGB),
shown steadily. State-indication changes — precharge start, contactor
close/open, and shifts into Reverse/Low/Med/High — flash over the top for 1 s
(a double-blink in that indication's color), then it reverts to the baseline.
FAULT always overrides with a continuous red blink (a latched fault must stay
loud). Until the dashboard sends its first LED, the legacy per-state colors show
(SAFE magenta/white · ARMED amber · DRIVE gear color green/cyan/blue · STOPPING
amber blink · FAULT red blink), so nothing regresses before the dashboard
connects. NB — the LED strip is now PWM-driven (analogWrite), reversing the
old "on/off only" rule that avoided LED-PWM coupling into the pin-2 hall input.
That coupling concern is why the speedometer moved to the isolated SPD pulse on
pin 22; if SPD/hall noise reappears under LED PWM, add source filtering or drop
LED PWM depth.
2. Binary telemetry channel (Teensy → Pi)¶
Frame format¶
LEN(uint8): number of bytes fromTYPEthrough end ofPAYLOAD(i.e.1 + payload_size). Max 64.CRC16: CRC-16/CCITT-FALSE (poly0x1021, init0xFFFF, no reflection, no final XOR) computed overLEN,TYPE, andPAYLOAD. Transmitted little-endian.- Resync: on CRC failure or invalid
LEN, the receiver discards bytes until the next0xF7.
All multi-byte payload fields are little-endian.
TYPE 0x01 — TELEMETRY_V1 (20 Hz, payload 30 bytes)¶
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | proto_ver |
= 1 |
| 1 | 1 | drive_state |
0 SAFE · 1 ARMED · 2 DRIVE · 3 STOPPING · 4 FAULT (STOPPING = controlled stop in progress: throttle cut, brake asserted, contactor still closed until the kart stops) |
| 2 | 1 | fault_code |
0 = none; see fault table below |
| 3 | 2 | status_flags (uint16) |
bit0 WHEEL_CONNECTED · bit1 STEER_LINK_OK · bit2 STEER_CALIBRATED · bit3 ESC_LINK_OK · bit4 CONTACTOR_CLOSED · bit5 REVERSE · bit6 BRAKE_ACTIVE · bit7 RC_LINK_UP · bit8 BENCH_MODE · bit9 PARK (shift ladder neutral) · rest reserved |
| 5 | 1 | throttle_pct (uint8) |
Pedal position 0–100 (driver input, like brake_pct; shows in any state. The DRIVE-gated, slew-limited DAC command is separate) |
| 6 | 1 | brake_pct (uint8) |
Pedal position 0–100 (output is binary in v1) |
| 7 | 2 | steer_setpoint_cdeg (int16) |
|
| 9 | 2 | steer_measured_cdeg (int16) |
From STEER_STATUS |
| 11 | 4 | hall_count (uint32) |
Cumulative pulses since boot |
| 15 | 2 | hall_hz_x10 (uint16) |
Pulse frequency ×10 (speed = f(pulses/rev, wheel ⌀) — computed Pi-side) |
| 17 | 2 | batt_dv (uint16) |
Battery decivolts. 0 until ESC telemetry lands (Phase 5) |
| 19 | 2 | batt_da (int16) |
Battery deciamps. 0 until Phase 5 |
| 21 | 2 | esc_rpm (int16) |
0 until Phase 5 |
| 23 | 1 | controller_temp_c (int8) |
−128 = unknown |
| 24 | 1 | motor_temp_c (int8) |
−128 = unknown |
| 25 | 4 | uptime_ms (uint32) |
|
| 29 | 1 | seq (uint8) |
Rolling; lets the Pi measure frame loss |
TYPE 0x02 — EVENT (on occurrence, payload 6 bytes)¶
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | event (1 STATE_CHANGE · 2 FAULT_RAISED · 3 FAULT_CLEARED · 4 WHEEL_CONNECT · 5 WHEEL_DISCONNECT) |
| 1 | 1 | arg (new state / fault code / 0) |
| 2 | 4 | uptime_ms (uint32) |
Events are also mirrored as INFO … text lines for terminal users.
Reserved types¶
0x03 STEER_DETAIL · 0x10 RC_CHANNELS · 0x20 ESC_RAW — defined when needed.
Versioning rules¶
Fields within TELEMETRY_V1 are append-only frozen: never reorder, repurpose,
or resize. A breaking change becomes TELEMETRY_V2 (TYPE 0x04), and the
firmware emits both during a transition window.
3. Fault codes¶
| Code | Name | Trigger | Recovery |
|---|---|---|---|
| 1 | WHEEL_LOST |
USB wheel disconnect / stale reports >100 ms in DRIVE | Reconnect wheel, FAULT_CLEAR, re-arm |
| 2 | STEER_TIMEOUT |
No STEER_STATUS for 150 ms |
Restore Steervo, clear, re-arm |
| 3 | STEER_FAULT |
Steervo reports FAULT state | Resolve Steervo fault bits first |
| 4 | PEDAL_IMPLAUSIBLE |
Pedal out-of-range / contradictory >250 ms | Release pedals, clear |
| 5 | DAC_ERROR |
MCP4725 I2C write NACK | Hardware check, clear |
| 6 | ARMED_TIMEOUT |
ARMED with no DRIVE entry for 10 s | Informational; auto-return to SAFE (not latched) |
| 7 | INTERNAL_WDT |
Loop overrun detected pre-watchdog-reset | Investigate before clearing |
| 8 | CONTACTOR_FAULT |
Contactor/bus sequencer fault: precharge on-time exceeded its 5 s hard cap (resistor shed, contactor left open), or bus-voltage sense never confirmed charge (sense line not wired today) | Inspect contactor + bus, clear, re-arm |