SAUL-TEE System Reference — SaltyLab ESP32 Architecture
Authoritative source of truth for hardware, pins, protocols, and CAN assignments.
Spec from hal@Orin, 2026-04-04.
Overview
| Board |
Role |
MCU |
USB chip |
| ESP32-S3 BALANCE |
PID balance loop, CAN→VESCs, LCD display |
ESP32-S3 |
CH343 USB-serial |
| ESP32-S3 IO |
RC input, motor drivers, sensors, LEDs, peripherals |
ESP32-S3 |
JTAG USB (native) |
Robot form factor: 4-wheel wagon — 870 × 510 × 550 mm, ~23 kg
Power: 36 V LiPo, DC-DC → 5 V and 12 V rails
Orin connection: CANable2 USB → 500 kbps CAN (same bus as VESCs)
ESP32-S3 BALANCE
Board
Waveshare ESP32-S3 Touch LCD 1.28
- GC9A01 round 240×240 LCD
- CST816S capacitive touch
- QMI8658 6-axis IMU (accel + gyro, SPI)
- CH343 USB-to-serial chip
Pin Assignments
| Function |
GPIO |
Notes |
| QMI8658 IMU (SPI) |
|
|
| SCK |
IO39 |
|
| MOSI |
IO38 |
|
| MISO |
IO40 |
|
| CS |
IO41 |
|
| INT1 |
IO42 |
data-ready interrupt |
| GC9A01 LCD (shares SPI bus) |
|
|
| CS |
IO12 |
|
| DC |
IO11 |
|
| RST |
IO10 |
|
| BL |
IO9 |
PWM backlight |
| CST816S Touch (I2C) |
|
|
| SDA |
IO4 |
|
| SCL |
IO5 |
|
| INT |
IO6 |
|
| RST |
IO7 |
|
| CAN — SN65HVD230 transceiver |
|
500 kbps |
| TX |
IO43 |
→ SN65HVD230 TXD |
| RX |
IO44 |
← SN65HVD230 RXD |
| Inter-board UART (to IO board) |
|
460800 baud |
| TX |
IO17 |
|
| RX |
IO18 |
|
Responsibilities
- Read QMI8658 @ 1 kHz (SPI, INT1-driven)
- Complementary filter → pitch angle
- PID balance loop (configurable Kp / Ki / Kd)
- Send VESC speed commands via CAN (ID 68 = left, ID 56 = right)
- Receive Orin velocity+mode commands via CAN (0x300–0x303)
- Receive IO board status (arming, RC, faults) via UART protocol
- Drive GC9A01 LCD: pitch, speed, battery %, error state
- Enforce tilt cutoff at ±25°; IWDG 50 ms timeout
- Publish telemetry on CAN 0x400–0x401 at 10 Hz
ESP32-S3 IO
Board
Bare ESP32-S3 devkit (JTAG USB)
Pin Assignments
| Function |
GPIO |
Notes |
| TBS Crossfire RC — UART0 (primary) |
|
|
| RX |
IO44 |
CRSF frames from Crossfire RX |
| TX |
IO43 |
telemetry to Crossfire TX |
| ELRS failover — UART2 |
|
active if CRSF absent >100 ms |
| RX |
IO16 |
|
| TX |
IO17 |
|
| BTS7960 Motor Driver — Left |
|
|
| RPWM |
IO1 |
forward PWM |
| LPWM |
IO2 |
reverse PWM |
| R_EN |
IO3 |
right enable |
| L_EN |
IO4 |
left enable |
| BTS7960 Motor Driver — Right |
|
|
| RPWM |
IO5 |
|
| LPWM |
IO6 |
|
| R_EN |
IO7 |
|
| L_EN |
IO8 |
|
| I2C bus |
|
|
| SDA |
IO11 |
|
| SCL |
IO12 |
|
| NFC (PN532 or similar) |
I2C |
|
| Barometer (BMP280/BMP388) |
I2C |
|
| ToF (VL53L0X/VL53L1X) |
I2C |
|
| WS2812B LEDs |
|
|
| Data |
IO13 |
|
| Outputs |
|
|
| Horn / buzzer |
IO14 |
PWM tone |
| Headlight |
IO15 |
PWM or digital |
| Fan |
IO16 |
(if ELRS not fitted on UART2) |
| Inputs |
|
|
| Arming button |
IO9 |
active-low, hold 3 s to arm |
| Kill switch sense |
IO10 |
hardware estop detect |
| Inter-board UART (to BALANCE board) |
|
460800 baud |
| TX |
IO18 |
|
| RX |
IO21 |
|
Responsibilities
- Parse CRSF frames (TBS Crossfire, primary)
- Parse ELRS frames (failover, activates if no CRSF for >100 ms)
- Drive BTS7960 left/right PWM motor drivers
- Read NFC, barometer, ToF via I2C
- Drive WS2812B LEDs (armed/fault/idle patterns)
- Control horn, headlight, fan, buzzer
- Manage arming: hold button 3 s while upright → send ARM to BALANCE
- Monitor kill switch input → immediate motor off + FAULT frame
- Forward RC + sensor data to BALANCE via binary UART protocol
- Report faults and RC-loss upstream
Inter-Board Binary Protocol (UART @ 460800 baud)
[0xAA][LEN][TYPE][PAYLOAD × LEN bytes][CRC8]
0xAA — start byte
LEN — payload length in bytes (uint8)
TYPE — message type (uint8)
CRC8 — CRC-8/MAXIM over TYPE + PAYLOAD bytes
IO → BALANCE Messages
| TYPE |
Name |
Payload |
Description |
| 0x01 |
RC_CMD |
int16 throttle, int16 steer, uint8 flags |
flags: bit0=armed, bit1=kill |
| 0x02 |
SENSOR |
uint16 tof_mm, int16 baro_delta_pa, uint8 nfc_present |
|
| 0x03 |
FAULT |
uint8 fault_flags |
bit0=rc_loss, bit1=motor_fault, bit2=estop |
BALANCE → IO Messages
| TYPE |
Name |
Payload |
Description |
| 0x10 |
STATE |
int16 pitch_x100, int16 pid_out, uint8 error_state |
|
| 0x11 |
LED_CMD |
uint8 pattern, uint8 r, uint8 g, uint8 b |
|
| 0x12 |
BUZZER |
uint8 tone_id, uint16 duration_ms |
|
CAN Bus — 500 kbps
Node Assignments
| Node |
CAN ID |
Role |
| VESC Left motor |
68 |
Receives speed/duty via VESC CAN protocol |
| VESC Right motor |
56 |
Receives speed/duty via VESC CAN protocol |
| ESP32-S3 BALANCE |
— |
Sends VESC commands; publishes telemetry |
| Jetson Orin (CANable2) |
— |
Sends velocity commands; receives telemetry |
Frame Table
| CAN ID |
Direction |
Description |
Rate |
| 0x300 |
Orin → BALANCE |
Velocity cmd: int16 speed_mmps, int16 steer_mrad |
20 Hz |
| 0x301 |
Orin → BALANCE |
PID tuning: float Kp, float Ki, float Kd (3×4B IEEE-754) |
on demand |
| 0x302 |
Orin → BALANCE |
Mode: uint8 (0=off, 1=balance, 2=manual, 3=estop) |
on demand |
| 0x303 |
Orin → BALANCE |
Config: uint16 tilt_limit_x100, uint16 max_speed_mmps |
on demand |
| 0x400 |
BALANCE → Orin |
Telemetry A: int16 pitch_x100, int16 pid_out, int16 speed_mmps, uint8 state |
10 Hz |
| 0x401 |
BALANCE → Orin |
Telemetry B: int16 vesc_l_rpm, int16 vesc_r_rpm, uint16 battery_mv, uint8 faults |
10 Hz |
RC Channel Mapping (TBS Crossfire / ELRS CRSF)
| CH |
Function |
Range (µs) |
Notes |
| 1 |
Steer (Roll) |
988–2012 |
±100% → ±max steer |
| 2 |
Throttle (Pitch) |
988–2012 |
forward / back speed |
| 3 |
Spare |
988–2012 |
|
| 4 |
Spare |
988–2012 |
|
| 5 |
ARM switch |
<1500=disarm, >1500=arm |
SB on TX |
| 6 |
ESTOP |
<1500=normal, >1500=kill |
SC on TX — checked first every loop |
| 7 |
Speed limit |
988–2012 |
maps to 10–100% speed cap |
| 8 |
Spare |
|
|
RC loss: No valid CRSF frame >100 ms → IO sends FAULT(rc_loss) → BALANCE cuts motors.
Safety Invariants
- Motors NEVER spin on power-on — 3 s button hold required while upright
- Tilt cutoff ±25° — immediate motor zero, manual re-arm required
- IWDG 50 ms — firmware hang → motors cut
- ESTOP RC channel checked first in every loop iteration
- Orin CAN timeout 500 ms → revert to RC-only mode
- Speed hard cap — start at 10%, increase in 10% increments only after stable tethered testing
- Never untethered until stable for 5+ continuous minutes tethered
USB Debug Commands (both boards, serial console)
help list commands
status print pitch, PID state, CAN stats, UART stats
pid <Kp> <Ki> <Kd> set PID gains
arm arm (if upright and safe)
disarm disarm immediately
estop emergency stop (requires re-arm)
tilt_limit <deg> set tilt cutoff angle (default 25)
speed_limit <pct> set speed cap percentage (default 10)
can_stats CAN bus counters (tx/rx/errors/busoff)
uart_stats inter-board UART frame counters
reboot soft reboot