Skip to content

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:

  1. ASCII command channel (Pi → Teensy requests, Teensy → Pi OK/ERR/INFO lines) — human-readable, usable from a terminal, backward compatible with the legacy bring-up firmware.
  2. 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

[0xF7] [LEN] [TYPE] [PAYLOAD …] [CRC16_LO] [CRC16_HI]
  • LEN (uint8): number of bytes from TYPE through end of PAYLOAD (i.e. 1 + payload_size). Max 64.
  • CRC16: CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection, no final XOR) computed over LEN, TYPE, and PAYLOAD. Transmitted little-endian.
  • Resync: on CRC failure or invalid LEN, the receiver discards bytes until the next 0xF7.

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