cleanup: remove all Mamba/F722S/STM32F722 refs — replace with ESP32-S3 BALANCE/IO

- docs/: rewrite AGENTS.md, wiring-diagram.md (SAUL-TEE arch); update
  SALTYLAB.md, FACE_LCD_ANIMATION.md, board-viz.html, SALTYLAB-DETAILED refs
- cad/: dimensions.scad FC params → ESP32-S3 BALANCE params
- chassis/: ASSEMBLY.md, BOM.md, ip54_BOM.md, *.scad — FC_MOUNT_SPACING/
  FC_PITCH → TBD ESP32-S3; Drone FC → MCU mount throughout
- CLAUDE.md, TEAM.md: project desc → SAUL-TEE; hardware table → ESP32-S3/VESC
- USB_CDC_BUG.md: marked ARCHIVED (legacy STM32 era)
- AUTONOMOUS_ARMING.md: USB CDC → inter-board UART (ESP32-S3 BALANCE)
- projects/saltybot/SLAM-SETUP-PLAN.md: FC/STM32F722 → BALANCE/CAN
- jetson/docs/pinout.md, power-budget.md, README.md: STM32 bridge → CAN bridge
- jetson/config/RECOVERY_BEHAVIORS.md: FC+Hoverboard → BALANCE+VESC
- jetson/ros2_ws: stm32_protocol.py → esp32_protocol.py,
  stm32_cmd_node.py → esp32_cmd_node.py,
  mamba_protocol.py → balance_protocol.py; can_bridge_node imports updated
- scripts/flash_firmware.py: DFU/STM32 → pio run -t upload
- src/ include/: ARCHIVED headers added (legacy code preserved)
- test/: ARCHIVED notices; STM32F722 comments marked LEGACY
- ui/diagnostics_panel.html: Board/STM32 → ESP32-S3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sl-mechanical 2026-04-04 09:05:47 -04:00
parent 9cf98830c6
commit a2c554c232
63 changed files with 1328 additions and 1789 deletions

View File

@ -7,11 +7,7 @@ The robot can now be armed and operated autonomously from the Jetson without req
### Jetson Autonomous Arming ### Jetson Autonomous Arming
- Command: `A\n` (single byte 'A' followed by newline) - Command: `A\n` (single byte 'A' followed by newline)
<<<<<<< HEAD - Sent via inter-board UART to the ESP32-S3 BALANCE firmware
- Sent via USB CDC to the ESP32 BALANCE firmware
=======
- Sent via USB Serial (CH343) to the ESP32-S3 firmware
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- Robot arms after ARMING_HOLD_MS (~500ms) safety hold period - Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
- Works even when RC is not connected or not armed - Works even when RC is not connected or not armed
@ -46,11 +42,11 @@ The robot can now be armed and operated autonomously from the Jetson without req
## Command Protocol ## Command Protocol
<<<<<<< HEAD ### Inter-board UART Protocol
### From Jetson to ESP32 BALANCE (USB CDC) Communication uses 460800 baud UART with binary framing:
======= `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
### From Jetson to ESP32-S3 (USB Serial (CH343))
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) ### From Jetson to ESP32-S3 BALANCE (inter-board UART)
``` ```
A — Request arm (triggers safety hold, then motors enable) A — Request arm (triggers safety hold, then motors enable)
D — Request disarm (immediate motor stop) D — Request disarm (immediate motor stop)
@ -60,11 +56,7 @@ H — Heartbeat (refresh timeout timer, every 500ms)
C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat) C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat)
``` ```
<<<<<<< HEAD ### From ESP32-S3 BALANCE to Jetson (inter-board UART)
### From ESP32 BALANCE to Jetson (USB CDC)
=======
### From ESP32-S3 to Jetson (USB Serial (CH343))
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
Motor commands are gated by `bal.state == BALANCE_ARMED`: Motor commands are gated by `bal.state == BALANCE_ARMED`:
- When ARMED: Motor commands sent every 20ms (50 Hz) - When ARMED: Motor commands sent every 20ms (50 Hz)
- When DISARMED: Zero sent every 20ms (prevents ESC timeout) - When DISARMED: Zero sent every 20ms (prevents ESC timeout)
@ -145,4 +137,4 @@ When RC is disconnected:
## References ## References
- Issue #512: Remove ELRS arm requirement - Issue #512: Remove ELRS arm requirement
- Files: `/src/main.c` (arming logic), `/lib/USB_CDC/src/usbd_cdc_if.c` (CDC commands) - Files: `esp32/balance/src/main.cpp` (arming logic), inter-board UART protocol (460800 baud, `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`)

View File

@ -1,36 +1,17 @@
# SaltyLab Firmware — Agent Playbook # SaltyLab Firmware — Agent Playbook
## Project ## Project
<<<<<<< HEAD SAUL-TEE 4-wheel wagon robot: ESP32-S3 BALANCE (PID/CAN), ESP32-S3 IO (RC/sensors), Jetson Orin Nano Super (ROS2/SLAM).
**SAUL-TEE** — 4-wheel wagon (870×510×550 mm, 23 kg).
Two ESP32-S3 boards + Jetson Orin via CAN. Full spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md`
| Board | Role |
|-------|------|
| **ESP32-S3 BALANCE** | QMI8658 IMU, PID balance, CAN→VESC (L:68 / R:56), GC9A01 LCD (Waveshare Touch LCD 1.28) |
| **ESP32-S3 IO** | TBS Crossfire RC, ELRS failover, BTS7960 motors, NFC/baro/ToF, WS2812 |
| **Jetson Orin** | AI/SLAM, CANable2 USB→CAN, cmds 0x3000x303, telemetry 0x4000x401 |
> **Legacy:** `src/` and `include/` = archived STM32 HAL — do not extend. New firmware in `esp32/`.
=======
Self-balancing two-wheeled robot: ESP32-S3 ESP32-S3 BALANCE, hoverboard hub motors, Jetson Orin Nano Super for AI/SLAM.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Team ## Team
| Agent | Role | Focus | | Agent | Role | Focus |
|-------|------|-------| |-------|------|-------|
<<<<<<< HEAD | **sl-firmware** | Embedded Firmware Lead | ESP32-S3 firmware (Arduino/IDF), PlatformIO, CAN bus, inter-board UART protocol |
| **sl-firmware** | Embedded Firmware Lead | ESP32-S3, ESP-IDF, QMI8658, CAN/UART protocol, BTS7960 |
| **sl-controls** | Control Systems Engineer | PID tuning, IMU fusion, balance loop, safety |
| **sl-perception** | Perception / SLAM Engineer | Jetson Orin, RealSense D435i, RPLIDAR, ROS2, Nav2 |
=======
| **sl-firmware** | Embedded Firmware Lead | ESP-IDF, USB Serial (CH343) debugging, SPI/UART, PlatformIO, DFU bootloader |
| **sl-controls** | Control Systems Engineer | PID tuning, IMU sensor fusion, real-time control loops, safety systems | | **sl-controls** | Control Systems Engineer | PID tuning, IMU sensor fusion, real-time control loops, safety systems |
| **sl-perception** | Perception / SLAM Engineer | Jetson Orin Nano Super, RealSense D435i, RPLIDAR, ROS2, Nav2 | | **sl-perception** | Perception / SLAM Engineer | Jetson Orin, RealSense D435i, RPLIDAR, ROS2, Nav2 |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Status ## Status
USB Serial (CH343) TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG ordering fix). USB CDC TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG ordering fix).
## Repo Structure ## Repo Structure
- `projects/saltybot/SALTYLAB.md` — Design doc - `projects/saltybot/SALTYLAB.md` — Design doc
@ -48,11 +29,11 @@ USB Serial (CH343) TX bug resolved (PR #10 — DCache MPU non-cacheable region +
| `saltyrover-dev` | Integration — rover variant | | `saltyrover-dev` | Integration — rover variant |
| `saltytank` | Stable — tracked tank variant | | `saltytank` | Stable — tracked tank variant |
| `saltytank-dev` | Integration — tank variant | | `saltytank-dev` | Integration — tank variant |
| `main` | Shared code only (IMU drivers, USB Serial (CH343), balance core, safety) | | `main` | Shared code only (IMU drivers, USB CDC, balance core, safety) |
### Rules ### Rules
- Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev` - Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev`
- Shared/infrastructure code (IMU drivers, USB Serial (CH343), balance core, safety) goes in `main` - Shared/infrastructure code (IMU drivers, USB CDC, balance core, safety) goes in `main`
- Variant-specific code (motor topology, kinematics, config) goes in variant branches - Variant-specific code (motor topology, kinematics, config) goes in variant branches
- Stable branches get promoted from `-dev` after review and hardware testing - Stable branches get promoted from `-dev` after review and hardware testing
- **Current SaltyLab team** works against `saltylab-dev` - **Current SaltyLab team** works against `saltylab-dev`

65
TEAM.md
View File

@ -1,22 +1,12 @@
# SaltyLab — Ideal Team # SaltyLab — Ideal Team
## Project ## Project
<<<<<<< HEAD SAUL-TEE 4-wheel wagon robot using ESP32-S3 BALANCE (PID/CAN master) and ESP32-S3 IO (RC/sensors), with Jetson Orin Nano Super for AI/SLAM.
**SAUL-TEE** — 4-wheel wagon (870×510×550 mm, 23 kg).
Two ESP32-S3 boards (BALANCE + IO) + Jetson Orin. See `docs/SAUL-TEE-SYSTEM-REFERENCE.md`.
## Current Status ## Current Status
- **Hardware:** ESP32-S3 BALANCE (Waveshare Touch LCD 1.28, CH343 USB) + ESP32-S3 IO (bare devkit, JTAG USB) - **Hardware:** Assembled — ESP32-S3 BALANCE + IO, VESCs, IMU, battery, RC all on hand
- **Firmware:** ESP-IDF/PlatformIO target; legacy `src/` STM32 HAL archived - **Firmware:** Balance PID + VESC CAN protocol written, ESP32-S3 inter-board UART protocol active
- **Comms:** UART 460800 baud inter-board; CANable2 USB→CAN for Orin; CAN 500 kbps to VESCs (L:68 / R:56) - **Status:** See current bead list for active issues
=======
Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hoverboard hub motors, and eventually a Jetson Orin Nano Super for AI/SLAM.
## Current Status
- **Hardware:** Assembled — FC, motors, ESC, IMU, battery, RC all on hand
- **Firmware:** Balance PID + hoverboard ESC protocol written, but blocked by USB Serial (CH343) bug
- **Blocker:** USB Serial (CH343) TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB on ESP32-S3 — see `legacy/stm32/USB_CDC_BUG.md` for historical context
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -24,30 +14,18 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
### 1. Embedded Firmware Engineer (Lead) ### 1. Embedded Firmware Engineer (Lead)
**Must-have:** **Must-have:**
<<<<<<< HEAD - ESP32-S3 firmware (Arduino / ESP-IDF framework)
- Deep ESP32 (Arduino/ESP-IDF) or STM32 HAL experience - PlatformIO toolchain
- USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts) - CAN bus protocol and VESC CAN integration
- SPI + UART + USB coexistence on ESP32 - Inter-board UART protocol (460800 baud, binary framed)
- PlatformIO or bare-metal ESP32 toolchain - Safety system design (tilt cutoff, watchdog, arming sequences)
- DFU bootloader implementation
=======
- Deep ESP-IDF experience (ESP32-S3 specifically)
- USB Serial (CH343) / UART debugging on ESP32-S3
- SPI + UART + USB coexistence on ESP32-S3
- ESP-IDF / Arduino-ESP32 toolchain
- OTA firmware update implementation
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
**Nice-to-have:** **Nice-to-have:**
- ESP32-S3 peripheral coexistence (SPI + UART + USB) - VESC firmware / VESC Tool experience
- PID control loop tuning for balance robots - PID control loop tuning for balance robots
- FOC motor control (hoverboard ESC protocol) - ELRS/CRSF RC protocol
<<<<<<< HEAD **Why:** Core firmware runs on ESP32-S3 BALANCE (PID/CAN master) and ESP32-S3 IO (RC/sensors). Need expertise in ESP32-S3 firmware and CAN bus integration with VESC motor controllers.
**Why:** The immediate blocker is a USB peripheral conflict. Need someone who's debugged STM32 USB issues before — ESP32 firmware for the balance loop and I/O needs to be written from scratch.
=======
**Why:** The immediate blocker is a USB peripheral conflict on ESP32-S3. Need someone who's debugged ESP32-S3 USB Serial (CH343) issues before — this is not a software logic bug, it's a hardware peripheral interaction issue.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### 2. Control Systems / Robotics Engineer ### 2. Control Systems / Robotics Engineer
**Must-have:** **Must-have:**
@ -57,7 +35,7 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
- Safety system design (tilt cutoff, watchdog, arming sequences) - Safety system design (tilt cutoff, watchdog, arming sequences)
**Nice-to-have:** **Nice-to-have:**
- Hoverboard hub motor experience - VESC motor controller experience
- ELRS/CRSF RC protocol - ELRS/CRSF RC protocol
- ROS2 integration - ROS2 integration
@ -65,7 +43,7 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
### 3. Perception / SLAM Engineer (Phase 2) ### 3. Perception / SLAM Engineer (Phase 2)
**Must-have:** **Must-have:**
- Jetson Orin Nano Super / NVIDIA Jetson platform - Jetson Orin Nano Super / NVIDIA Jetson platform (JetPack 6)
- Intel RealSense D435i depth camera - Intel RealSense D435i depth camera
- RPLIDAR integration - RPLIDAR integration
- SLAM (ORB-SLAM3, RTAB-Map, or similar) - SLAM (ORB-SLAM3, RTAB-Map, or similar)
@ -83,13 +61,12 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
## Hardware Reference ## Hardware Reference
| Component | Details | | Component | Details |
|-----------|---------| |-----------|---------|
<<<<<<< HEAD | BALANCE MCU | ESP32-S3 BALANCE (Waveshare Touch LCD 1.28, QMI8658 IMU) |
| FC | ESP32 BALANCE (ESP32RET6, MPU6000) | | IO MCU | ESP32-S3 IO (RC/sensors/LEDs board) |
======= | Motors | 2x 8" pneumatic hub motors |
| FC | ESP32-S3 BALANCE (ESP32-S3RET6, QMI8658) | | ESC Left | VESC left (CAN ID 68) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) | ESC Right | VESC right (CAN ID 56) |
| Motors | 2x 8" pneumatic hoverboard hub motors | | CAN Bridge | CANable 2.0 (Jetson USB → can0, 500 kbps) |
| ESC | Hoverboard ESC (EFeru FOC firmware) |
| Battery | 36V pack | | Battery | 36V pack |
| RC | BetaFPV ELRS 2.4GHz TX + RX | | RC | BetaFPV ELRS 2.4GHz TX + RX |
| AI Brain | Jetson Orin Nano Super + Noctua fan | | AI Brain | Jetson Orin Nano Super + Noctua fan |
@ -100,4 +77,4 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
## Repo ## Repo
- Gitea: https://gitea.vayrette.com/seb/saltylab-firmware - Gitea: https://gitea.vayrette.com/seb/saltylab-firmware
- Design doc: `projects/saltybot/SALTYLAB.md` - Design doc: `projects/saltybot/SALTYLAB.md`
- Bug doc: `legacy/stm32/USB_CDC_BUG.md` (archived — STM32 era) - Archived bug doc: `USB_CDC_BUG.md` (legacy STM32 era)

View File

@ -14,11 +14,12 @@ motor_axle_flat = 10; // Flat-to-flat if D-shaft
motor_body_dia = 200; // ~8 inches motor_body_dia = 200; // ~8 inches
motor_bolt_circle = 0; // Axle-only mount (clamp style) motor_bolt_circle = 0; // Axle-only mount (clamp style)
// --- Drone FC (30.5mm standard) --- // --- ESP32-S3 BALANCE board (Waveshare Touch LCD 1.28) ---
fc_hole_spacing = 25.5; // GEP-F722 AIO v2 (not standard 30.5!) // Confirm hole positions before printing verify in esp32/balance/src/config.h
fc_hole_dia = 3.2; // M3 clearance mcu_bal_board_w = 40.0; // Waveshare Touch LCD 1.28 PCB approx width (TBD caliper)
fc_board_size = 36; // Typical FC PCB mcu_bal_board_d = 40.0; // Waveshare Touch LCD 1.28 PCB approx depth (TBD caliper)
fc_standoff_h = 5; // Rubber standoff height mcu_bal_hole_dia = 3.2; // M3 clearance
mcu_standoff_h = 5; // Standoff height
// --- Jetson Orin Nano Super --- // --- Jetson Orin Nano Super ---
jetson_w = 100; jetson_w = 100;

View File

@ -10,7 +10,7 @@
├─ bumper_bracket(front=+1) ──────────────────────┐ ├─ bumper_bracket(front=+1) ──────────────────────┐
│ │ │ │
┌───────┴──────────── Main Deck (640×220×6mm Al) ─────────┴───────┐ ┌───────┴──────────── Main Deck (640×220×6mm Al) ─────────┴───────┐
│ ← Jetson mount plate (rear/+X) FC mount (front/X) → │ ← Jetson mount plate (rear/+X) MCU mount (front/X) →
│ [Battery tray hanging below centre] │ │ [Battery tray hanging below centre] │
└───┬──────────────────────────────────────────────────────────┬───┘ └───┬──────────────────────────────────────────────────────────┬───┘
│ │ │ │
@ -56,24 +56,21 @@
3. Fasten 4× M4×12 SHCS. Torque 2.5 N·m. 3. Fasten 4× M4×12 SHCS. Torque 2.5 N·m.
4. Insert battery pack; route Velcro straps through slots and cinch. 4. Insert battery pack; route Velcro straps through slots and cinch.
<<<<<<< HEAD ### 7 MCU mount (ESP32-S3 BALANCE + ESP32-S3 IO)
### 7 MCU mount (ESP32 BALANCE + ESP32 IO)
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE retired. Two ESP32 boards replace it. > ⚠ Board hole patterns TBD — measure Waveshare Touch LCD 1.28 PCB with calipers and
> Board dimensions and hole patterns TBD — await spec from max before machining mount plate. > update `FC_PITCH` / `FC_MOUNT_SPACING` in all scad files before machining the mount plate.
> Reference: `docs/SAUL-TEE-SYSTEM-REFERENCE.md`.
======= 1. Place silicone anti-vibration grommets onto M3 nylon standoffs.
### 7 FC mount (ESP32-S3 BALANCE) 2. Lower ESP32-S3 BALANCE board onto standoffs; secure M3×6 BHCS — snug only.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) 3. Mount ESP32-S3 IO board adjacent — exact layout TBD pending board dimensions.
1. Place silicone anti-vibration grommets onto nylon M3 standoffs. 4. Orient USB-C connectors toward accessible side for field programming/debug.
2. Lower ESP32 BALANCE board onto standoffs; secure with M3×6 BHCS. Snug only.
3. Mount ESP32 IO board adjacent — exact placement TBD pending board dimensions.
4. Orient USB connectors toward front of robot for cable access.
### 8 Jetson Orin Nano Super mount plate ### 8 Jetson Nano mount plate
1. Press or thread M3 nylon standoffs (8mm) into plate holes. 1. Press or thread M3 nylon standoffs (8mm) into plate holes.
2. Bolt plate to deck: 4× M3×10 SHCS at deck corners. 2. Bolt plate to deck: 4× M3×10 SHCS at deck corners.
3. Set Jetson Orin Nano Super B01 carrier onto plate standoffs; fasten M3×6 BHCS. 3. Set Jetson Nano B01 carrier onto plate standoffs; fasten M3×6 BHCS.
### 9 Bumper brackets ### 9 Bumper brackets
1. Slide 22mm EMT conduit through saddle clamp openings. 1. Slide 22mm EMT conduit through saddle clamp openings.
@ -95,8 +92,8 @@
| Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm | | Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm |
| Motor fork slot width | 24 mm | +0.5 / 0 | | Motor fork slot width | 24 mm | +0.5 / 0 |
| Motor fork dropout depth | 60 mm | ±0.5 mm | | Motor fork dropout depth | 60 mm | ±0.5 mm |
| ESP32 BALANCE hole pattern | TBD — await spec from max | ±0.2 mm | | ESP32-S3 BALANCE hole pattern | TBD — caliper Waveshare board | ±0.2 mm |
| ESP32 IO hole pattern | TBD — await spec from max | ±0.2 mm | | ESP32-S3 IO hole pattern | TBD — caliper bare board | ±0.2 mm |
| Jetson hole pattern | 58 × 58 mm | ±0.2 mm | | Jetson hole pattern | 58 × 58 mm | ±0.2 mm |
| Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm | | Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm |

View File

@ -41,11 +41,7 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| 3 | Dropout clamp — upper | 2 | 8mm 6061-T6 Al | 90×70mm blank | D-cut bore; `RENDER="clamp_upper_2d"` | | 3 | Dropout clamp — upper | 2 | 8mm 6061-T6 Al | 90×70mm blank | D-cut bore; `RENDER="clamp_upper_2d"` |
| 4 | Stem flange ring | 2 | 6mm Al or acrylic | Ø82mm disc | One above + one below plate; `RENDER="stem_flange_2d"` | | 4 | Stem flange ring | 2 | 6mm Al or acrylic | Ø82mm disc | One above + one below plate; `RENDER="stem_flange_2d"` |
| 5 | Vertical stem tube | 1 | 38.1mm OD × 1.5mm wall 6061-T6 Al | 1050mm length | 1.5" EMT conduit is a drop-in alternative | | 5 | Vertical stem tube | 1 | 38.1mm OD × 1.5mm wall 6061-T6 Al | 1050mm length | 1.5" EMT conduit is a drop-in alternative |
<<<<<<< HEAD | 6 | MCU standoff M3×6mm nylon | 4 | Nylon | — | ESP32-S3 BALANCE / IO vibration isolation |
| 6 | MCU standoff M3×6mm nylon | 4 | Nylon | — | ESP32 BALANCE / IO board isolation (dimensions TBD) |
=======
| 6 | FC standoff M3×6mm nylon | 4 | Nylon | — | ESP32-S3 BALANCE vibration isolation |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment | | 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment |
### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B ### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B
@ -73,8 +69,8 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| 9 | Motor fork bracket (L) | 1 | 8mm 6061 aluminium | **Update fork slot to Ø16.51mm before cutting** | | 9 | Motor fork bracket (L) | 1 | 8mm 6061 aluminium | **Update fork slot to Ø16.51mm before cutting** |
| 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 | | 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 |
| 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module | | 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module |
| 12 | FC mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height | | 12 | MCU mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height — hole pattern TBD |
| 13 | Jetson Orin Nano Super mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern | | 13 | Jetson Nano mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern |
| 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit | | 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit |
| 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 | | 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 |
@ -92,23 +88,16 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
## Electronics Mounts ## Electronics Mounts
> ⚠ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE (ESP32) is retired. > ⚠ MCU board dimensions TBD — caliper Waveshare Touch LCD 1.28 and bare ESP32-S3 IO board
> Replaced by **ESP32 BALANCE** + **ESP32 IO**. Board dimensions and hole patterns TBD — await spec from max. > before machining mount holes. See `docs/SAUL-TEE-SYSTEM-REFERENCE.md`.
| # | Part | Qty | Spec | Notes | | # | Part | Qty | Spec | Notes |
|---|------|-----|------|-------| |---|------|-----|------|-------|
<<<<<<< HEAD | 13 | ESP32-S3 BALANCE (Waveshare Touch LCD 1.28) | 1 | ~40×40mm PCB, hole pattern TBD | PID loop + CAN; USB-C toward accessible side |
| 13 | ESP32 BALANCE board | 1 | TBD — mount pattern TBD | PID balance loop; replaces ESP32 BALANCE | | 13b | ESP32-S3 IO (bare board) | 1 | TBD PCB size, hole pattern TBD | RC / motor / sensor I/O |
| 13b | ESP32 IO board | 1 | TBD — mount pattern TBD | Motor/sensor/comms I/O |
| 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | ESP32 board isolation | | 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | ESP32 board isolation |
| 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under ESP32 mount pads | | 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under ESP32 mount pads |
| 16 | Jetson Orin module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern | | 16 | Jetson Orin Nano Super | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern |
=======
| 13 | ESP32-S3 ESP32-S3 BALANCE FC | 1 | 36×36mm PCB, 30.5×30.5mm M3 mount | Oriented USB-C port toward front |
| 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | FC vibration isolation |
| 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under FC mount pads |
| 16 | Jetson Orin Nano Super B01 module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs | | 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs |
--- ---
@ -159,8 +148,8 @@ Slide entire carousel up/down the stem with M6 collar bolts loosened. Tighten at
| 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts | | 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts |
| 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets | | 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets |
| 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) | | 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) |
| 29 | M3×10 SHCS | 12 | ISO 4762, SS | ESP32 mount + miscellaneous | | 29 | M3×10 SHCS | 12 | ISO 4762, SS | FC mount + miscellaneous |
| 30 | M3×6 BHCS | 4 | ISO 4762, SS | ESP32 board bolts (qty TBD pending board spec) | | 30 | M3×6 BHCS | 4 | ISO 4762, SS | FC board bolts |
| 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor | | 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor |
| 32 | Flat washer M5 | 32 | SS | | | 32 | Flat washer M5 | 32 | SS | |
| 33 | Flat washer M4 | 32 | SS | | | 33 | Flat washer M4 | 32 | SS | |

View File

@ -8,9 +8,9 @@
// Requirements: // Requirements:
// - 600mm wheelbase // - 600mm wheelbase
// - 2x hoverboard hub motors (170mm OD) // - 2x hoverboard hub motors (170mm OD)
// - ESP32-S3 ESP32-S3 BALANCE FC mount (30.5x30.5mm pattern) // - ESP32-S3 BALANCE + IO board mounts (TBD hole pattern see SAUL-TEE-SYSTEM-REFERENCE.md)
// - Battery tray (24V 4Ah ~180x70x50mm pack) // - Battery tray (24V 4Ah ~180x70x50mm pack)
// - Jetson Orin Nano Super B01 mount plate (100x80mm, M3 holes) // - Jetson Nano B01 mount plate (100x80mm, M3 holes)
// - Front/rear bumper brackets // - Front/rear bumper brackets
// ============================================================================= // =============================================================================
@ -37,8 +37,9 @@ MOTOR_FORK_H = 80; // mm, total height of motor fork bracket
MOTOR_FORK_T = 8; // mm, fork plate thickness MOTOR_FORK_T = 8; // mm, fork plate thickness
AXLE_HEIGHT = 310; // mm, axle CL above ground (motor radius + clearance) AXLE_HEIGHT = 310; // mm, axle CL above ground (motor radius + clearance)
// FC mount (ESP32-S3 BALANCE 30.5 × 30.5 mm M3 pattern) // MCU mount (ESP32-S3 BALANCE / IO boards)
FC_MOUNT_SPACING = 30.5; // mm, hole pattern pitch // Hole pattern TBD update before machining. See docs/SAUL-TEE-SYSTEM-REFERENCE.md
FC_MOUNT_SPACING = 0; // TBD set to actual ESP32-S3 board hole spacing
FC_MOUNT_HOLE_D = 3.2; // mm, M3 clearance FC_MOUNT_HOLE_D = 3.2; // mm, M3 clearance
FC_STANDOFF_H = 6; // mm, standoff height FC_STANDOFF_H = 6; // mm, standoff height
FC_PAD_T = 3; // mm, mounting pad thickness FC_PAD_T = 3; // mm, mounting pad thickness
@ -52,7 +53,7 @@ BATT_FLOOR = 4; // mm, tray floor thickness
BATT_STRAP_W = 20; // mm, Velcro strap slot width BATT_STRAP_W = 20; // mm, Velcro strap slot width
BATT_STRAP_T = 2; // mm, strap slot depth BATT_STRAP_T = 2; // mm, strap slot depth
// Jetson Orin Nano Super B01 mount plate // Jetson Nano B01 mount plate
// B01 carrier board hole pattern: 58 x 58 mm M3 (inner) + corner pass-throughs // B01 carrier board hole pattern: 58 x 58 mm M3 (inner) + corner pass-throughs
JETSON_HOLE_PITCH = 58; // mm, M3 mounting hole pattern JETSON_HOLE_PITCH = 58; // mm, M3 mounting hole pattern
JETSON_HOLE_D = 3.2; // mm JETSON_HOLE_D = 3.2; // mm
@ -210,7 +211,7 @@ module battery_tray() {
// FC mount holes helper // FC mount holes helper
module fc_mount_holes(z_offset=0, depth=10) { module fc_mount_holes(z_offset=0, depth=10) {
// ESP32-S3 BALANCE: 30.5×30.5 mm M3 pattern, centred at origin // ESP32-S3 board M3 pattern, centred at origin spacing TBD, update FC_MOUNT_SPACING
for (x = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2]) for (x = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2])
for (y = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2]) for (y = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2])
translate([x, y, z_offset]) translate([x, y, z_offset])
@ -247,7 +248,7 @@ module fc_mount_plate() {
} }
} }
// Jetson Orin Nano Super B01 mount plate // Jetson Nano B01 mount plate
// Positioned rear of deck, elevated on standoffs // Positioned rear of deck, elevated on standoffs
module jetson_mount_plate() { module jetson_mount_plate() {
jet_x = 60; // offset toward rear jet_x = 60; // offset toward rear

View File

@ -104,11 +104,8 @@ IP54-rated enclosures and sensor housings for all-weather outdoor robot operatio
| Component | Thermal strategy | Max junction | Enclosure budget | | Component | Thermal strategy | Max junction | Enclosure budget |
|-----------|-----------------|-------------|-----------------| |-----------|-----------------|-------------|-----------------|
| Jetson Orin NX | Al pad → lid → fan forced convection | 95 °C Tj | Target ≤ 60 °C case | | Jetson Orin NX | Al pad → lid → fan forced convection | 95 °C Tj | Target ≤ 60 °C case |
<<<<<<< HEAD | ESP32-S3 BALANCE | Passive; mounted on standoffs | 105 °C Tj | <70 °C ambient OK |
| FC (ESP32 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK | | ESP32-S3 IO | Passive; mounted on standoffs | 105 °C Tj | <70 °C ambient OK |
=======
| FC (ESP32-S3 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| ESC × 2 | Al pad → lid | 100 °C Tj | Target ≤ 60 °C | | ESC × 2 | Al pad → lid | 100 °C Tj | Target ≤ 60 °C |
| D435i | Passive; housing vent gap on rear cap | 45 °C surface | — | | D435i | Passive; housing vent gap on rear cap | 45 °C surface | — |

View File

@ -65,10 +65,11 @@ CLAMP_ALIGN_D = 4.1; // Ø4 pin
// D-cut bore clearance // D-cut bore clearance
DCUT_CL = 0.3; DCUT_CL = 0.3;
// FC mount ESP32-S3 BALANCE 30.5 × 30.5 mm M3 // MCU mount ESP32-S3 BALANCE board (Waveshare Touch LCD 1.28)
FC_PITCH = 30.5; // FC_PITCH TBD update before machining. See docs/SAUL-TEE-SYSTEM-REFERENCE.md
FC_PITCH = 0.0; // TBD ESP32-S3 board hole spacing not yet confirmed
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;
// FC is offset toward front of plate (away from stem) // Board is offset toward front of plate (away from stem)
FC_X_OFFSET = -40.0; // mm from plate centre (negative = front/motor side) FC_X_OFFSET = -40.0; // mm from plate centre (negative = front/motor side)
// ============================================================================= // =============================================================================
@ -202,7 +203,7 @@ module base_plate() {
translate([STEM_FLANGE_BC/2, 0, -1]) translate([STEM_FLANGE_BC/2, 0, -1])
cylinder(d=M5, h=PLATE_THICK + 2); cylinder(d=M5, h=PLATE_THICK + 2);
// FC mount (ESP32-S3 BALANCE 30.5 × 30.5 M3) // MCU mount (ESP32-S3 BALANCE hole spacing TBD)
for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2]) for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2])
for (y = [-FC_PITCH/2, FC_PITCH/2]) for (y = [-FC_PITCH/2, FC_PITCH/2])
translate([x, y, -1]) translate([x, y, -1])

View File

@ -10,8 +10,8 @@
// RPLIDAR A1M8 tower integrated on lid top // RPLIDAR A1M8 tower integrated on lid top
// Ventilation slots all 4 walls + lid // Ventilation slots all 4 walls + lid
// //
// Shared mounting patterns (swappable with SaltyLab): // Shared mounting patterns:
// FC : 30.5 × 30.5 mm M3 (ESP32-S3 BALANCE / Pixhawk) // MCU : TBD mm M3 (ESP32-S3 BALANCE / IO see docs/SAUL-TEE-SYSTEM-REFERENCE.md)
// Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier) // Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier)
// //
// Coordinate: bay centred at origin; Z=0 = deck top face. // Coordinate: bay centred at origin; Z=0 = deck top face.

View File

@ -16,8 +16,8 @@
// D435i front bracket arm // D435i front bracket arm
// Weight target: <2 kg frame (excl. motors/electronics) // Weight target: <2 kg frame (excl. motors/electronics)
// //
// Shared SaltyLab patterns (swappable electronics): // Shared patterns (swappable electronics):
// FC : 30.5 × 30.5 mm M3 (ESP32-S3 BALANCE / Pixhawk) // MCU : TBD mm M3 (ESP32-S3 BALANCE / IO see docs/SAUL-TEE-SYSTEM-REFERENCE.md)
// Jetson: 58 × 49 mm M3 (Orin NX / Nano carrier board) // Jetson: 58 × 49 mm M3 (Orin NX / Nano carrier board)
// Stem : Ø25 mm bore (sensor head unchanged) // Stem : Ø25 mm bore (sensor head unchanged)
// //
@ -87,9 +87,9 @@ STEM_COLLAR_OD = 50.0;
STEM_COLLAR_H = 20.0; // raised boss height above deck top STEM_COLLAR_H = 20.0; // raised boss height above deck top
STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter
// FC mount ESP32-S3 BALANCE / Pixhawk (30.5 × 30.5 mm M3) // MCU mount ESP32-S3 BALANCE / IO boards
// Shared with SaltyLab swappable electronics // FC_PITCH TBD update before machining. See docs/SAUL-TEE-SYSTEM-REFERENCE.md
FC_PITCH = 30.5; FC_PITCH = 0.0; // TBD ESP32-S3 board hole spacing
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;
FC_POS_Y = ROVER_L/2 - 65.0; // near front edge FC_POS_Y = ROVER_L/2 - 65.0; // near front edge

View File

@ -1,323 +1,184 @@
# AGENTS.md — SaltyLab Agent Onboarding # AGENTS.md — SAUL-TEE Agent Onboarding
You're working on **SaltyLab**, a self-balancing two-wheeled indoor robot. Read this entire file before touching anything. You're working on **SAUL-TEE**, a 4-wheel wagon robot. Read this entire file before touching anything.
## ⚠️ ARCHITECTURE — SAUL-TEE (finalised 2026-04-04) **Full system reference:** `docs/SAUL-TEE-SYSTEM-REFERENCE.md`
<<<<<<< HEAD ## Project Overview
Full hardware spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md` — **read it before writing firmware.**
| Board | Role | A 4-wheel wagon robot (870×510×550 mm, 23 kg) with three compute layers:
|-------|------|
| **ESP32-S3 BALANCE** | Waveshare Touch LCD 1.28 (CH343 USB). QMI8658 IMU, PID loop, CAN→VESC L(68)/R(56), GC9A01 LCD | 1. **ESP32-S3 BALANCE** (Waveshare Touch LCD 1.28) — QMI8658 IMU, PID drive / stability loop, CAN bus master for VESCs. Safety-critical layer. Firmware in `esp32/balance/`.
| **ESP32-S3 IO** | Bare devkit (JTAG USB). TBS Crossfire RC (UART0), ELRS failover (UART2), BTS7960 motors, NFC/baro/ToF, WS2812, buzzer/horn/headlight/fan | 2. **ESP32-S3 IO** (bare board) — RC input (TBS Crossfire + ELRS failover), BTS7960 motor drivers, I2C sensors (NFC/baro/ToF), WS2812 LEDs, accessories. Firmware in `esp32/io/`.
| **Jetson Orin** | CANable2 USB→CAN. Cmds on 0x3000x303, telemetry on 0x4000x401 | 3. **Jetson Orin Nano Super** — AI brain: ROS2, SLAM, Nav2, perception. Sends high-level velocity commands over CAN (0x3000x303). Receives telemetry on CAN (0x4000x401).
``` ```
Jetson Orin ──CANable2──► CAN 500kbps ◄───────────────────────┐ Orin (CAN 0x300-0x303) ←→ TBS Crossfire / ELRS (CRSF @ 420000)
│ │
ESP32-S3 BALANCE ←─UART 460800─► ESP32-S3 IO ▼ CAN 500kbps │ inter-board UART 460800
(QMI8658, PID loop) (BTS7960, RC, sensors) ESP32-S3 BALANCE ───────────────── ESP32-S3 IO
│ CAN 500kbps QMI8658 IMU BTS7960 × 4 motor drivers
┌─────────┴──────────┐ PID loop NFC / baro / ToF (I2C)
VESC Left (ID 68) VESC Right (ID 56) SN65HVD230 CAN WS2812 LEDs
======= │ Horn / headlight / fan / buzzer
A hoverboard-based balancing robot with two compute layers: ▼ CAN 500kbps
1. **ESP32-S3 BALANCE** — ESP32-S3 BALANCE (ESP32-S3RET6 + MPU6000 IMU). Runs a lean C balance loop at up to 8kHz. Talks UART to the hoverboard ESC. This is the safety-critical layer. VESC left (ID 68) VESC right (ID 56)
2. **Jetson Orin Nano Super** — AI brain. ROS2, SLAM, person tracking. Sends velocity commands to FC via UART. Not safety-critical — FC operates independently. │ │
Hub motors FL/RL Hub motors FR/RR
``` ```
Jetson (speed+steer via UART1) ←→ ELRS RC (UART3, kill switch)
ESP32-S3 BALANCE (MPU6000 IMU, PID balance)
▼ UART2
Hoverboard ESC (FOC) → 2× 8" hub motors
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```
Frame: `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
Legacy `src/` STM32 HAL code is **archived — do not extend.**
## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT ## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT
This is not a toy. 8" hub motors + 36V battery can crush fingers, break toes, and launch the frame. Every firmware change must preserve these invariants: This is not a toy. 4× hub motors + 36V × high-current VESCs can crush fingers, break toes, and throw the 23 kg frame. Every firmware change must preserve these invariants:
1. **Motors NEVER spin on power-on.** Requires deliberate arming: hold button 3s while upright. 1. **Motors NEVER spin on power-on.** Requires deliberate arming: deliberate ARM command.
2. **Tilt cutoff at ±25°** — motors to zero, require manual re-arm. No retry, no recovery. 2. **RC kill switch** — dedicated ELRS/Crossfire channel, checked every loop iteration. Always overrides.
3. **Hardware watchdog (50ms)** — if firmware hangs, motors cut. 3. **CAN watchdog** — if no Orin heartbeat for 500 ms, drop to RC-only mode.
4. **RC kill switch** — dedicated ELRS channel, checked every loop iteration. Always overrides. 4. **ESTOP CAN frame** — 0x303 with magic byte 0xE5 cuts all motors instantly.
5. **Jetson UART timeout (200ms)** — if Jetson disconnects, motors cut. 5. **Inter-board heartbeat** — if IO board misses BALANCE heartbeat for 200 ms, IO disables all BTS7960 enables.
6. **Speed hard cap** — firmware limit, start at 10%. Increase only after proven stable. 6. **Speed hard cap** — firmware limit, start at 10%. Increase only after proven stable.
7. **Never test untethered** until PID is stable for 5+ minutes on a tether. 7. **Never test without RC transmitter in hand.**
**If you break any of these, you are removed from the project.** **If you break any of these, you are removed from the project.**
## Repository Layout ## Repository Layout
``` ```
<<<<<<< HEAD esp32/
firmware/ # Legacy ESP32/STM32 HAL firmware (PlatformIO, archived) ├── balance/ — ESP32-S3 BALANCE firmware (PlatformIO)
======= │ ├── src/
firmware/ # ESP-IDF firmware (PlatformIO) │ │ ├── main.cpp
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) │ │ ├── config.h ← GPIO assignments — update here first
├── src/ │ │ ├── imu_qmi8658.cpp/.h
│ ├── main.c # Entry point, clock config, main loop │ │ ├── can_vesc.cpp/.h
│ ├── icm42688.c # QMI8658-P SPI driver (backup IMU — currently broken) │ │ └── protocol.cpp/.h
│ ├── bmp280.c # Barometer driver (disabled) │ └── platformio.ini
│ └── status.c # LED + buzzer status patterns ├── io/ — ESP32-S3 IO firmware (PlatformIO)
├── include/ │ ├── src/
│ ├── config.h # Pin definitions, constants │ │ ├── main.cpp
│ ├── icm42688.h │ │ ├── config.h ← GPIO assignments — update here first
│ ├── mpu6000.h # MPU6000 driver header (primary IMU) │ │ ├── rc_crsf.cpp/.h
│ ├── hoverboard.h # Hoverboard ESC UART protocol │ │ ├── motor_bts7960.cpp/.h
│ ├── crsf.h # ELRS CRSF protocol │ │ └── protocol.cpp/.h
│ ├── bmp280.h │ └── platformio.ini
│ └── status.h └── shared/
├── lib/USB_CDC/ # USB Serial (CH343) stack (serial over USB) └── protocol.h ← inter-board frame types — authoritative
│ ├── src/ # CDC implementation, USB descriptors, PCD config
│ └── include/
└── platformio.ini # Build config
cad/ # OpenSCAD parametric parts (16 files) src/ — LEGACY STM32 code (ARCHIVED — do not touch)
├── dimensions.scad # ALL measurements live here — single source of truth include/ — LEGACY STM32 headers (ARCHIVED — do not touch)
├── assembly.scad # Full robot assembly visualization
├── motor_mount_plate.scad
├── battery_shelf.scad
├── fc_mount.scad # Vibration-isolated FC mount
├── jetson_shelf.scad
├── esc_mount.scad
├── sensor_tower_top.scad
├── lidar_standoff.scad
├── realsense_bracket.scad
├── bumper.scad # TPU bumpers (front + rear)
├── handle.scad
├── kill_switch_mount.scad
├── tether_anchor.scad
├── led_diffuser_ring.scad
└── esp32c3_mount.scad
ui/ # Web UI (Three.js + WebSerial) chassis/ — OpenSCAD mechanical parts
└── index.html # 3D board visualization, real-time IMU data ├── ASSEMBLY.md — assembly instructions
├── BOM.md — bill of materials
└── *.scad — parametric parts
SALTYLAB.md # Master design doc — architecture, wiring, build phases docs/
SALTYLAB-DETAILED.md # Power budget, weight budget, detailed schematics ├── SAUL-TEE-SYSTEM-REFERENCE.md ← MASTER REFERENCE — read this
PLATFORM.md # Hardware platform reference ├── AGENTS.md — this file
├── wiring-diagram.md — wiring reference (see SAUL-TEE-SYSTEM-REFERENCE.md)
└── SALTYLAB.md — legacy design doc (historical)
``` ```
## Hardware Quick Reference ## Hardware Quick Reference
<<<<<<< HEAD ### ESP32-S3 BALANCE (Waveshare Touch LCD 1.28)
### ESP32 BALANCE Flight Controller
| Spec | Value | | Spec | Value |
|------|-------| |------|-------|
| MCU | ESP32RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) | | MCU | ESP32-S3, dual-core 240 MHz, 8MB flash, 8MB PSRAM |
======= | USB | CH343G USB-UART bridge (UART0 / GPIO43 TX, GPIO44 RX) |
### ESP32-S3 BALANCE Flight Controller | Display | 1.28" round GC9A01 240×240 (SPI, onboard) |
| IMU | QMI8658 6-axis (I2C-0 SDA=GPIO6, SCL=GPIO7, INT=GPIO3) |
| CAN | SN65HVD230 external transceiver (GPIO TBD — see `esp32/balance/src/config.h`) |
| Inter-board UART | UART1 (GPIO TBD) ↔ ESP32-IO @ 460800 baud |
| Spec | Value | ### ESP32-S3 IO (bare board)
|------|-------|
| MCU | ESP32-S3RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Primary IMU | MPU6000 (WHO_AM_I = 0x68) |
| IMU Bus | SPI1: PA5=SCK, PA6=MISO, PA7=MOSI, CS=PA4 |
| IMU EXTI | PC4 (data ready interrupt) |
| IMU Orientation | CW270 (Betaflight convention) |
| Secondary IMU | QMI8658-P (on same SPI1, CS unknown — currently non-functional) |
| Betaflight Target | DIAT-MAMBAF722_2022B |
| USB | OTG FS (PA11/PA12), enumerates as /dev/cu.usbmodemSALTY0011 |
| VID/PID | 0x0483/0x5740 |
| LEDs | PC15 (LED1), PC14 (LED2), active low |
| Buzzer | PB2 (inverted push-pull) |
| Battery ADC | PC1=VBAT, PC3=CURR (ADC3) |
| DFU | Hold yellow BOOT button + plug USB (or send 'R' over CDC) |
### UART Assignments | Peripheral | Interface | GPIO |
|------------|-----------|------|
| TBS Crossfire RX | UART0 CRSF @ 420000 | GPIO43 TX / GPIO44 RX |
| ELRS failover RX | UART2 CRSF @ 420000 | TBD |
| BTS7960 FL/FR/RL/RR | PWM + GPIO | TBD — see config.h |
| I2C bus (NFC/baro/ToF) | I2C | TBD |
| WS2812B LEDs | RMT GPIO | TBD |
| Horn / headlight / fan / buzzer | GPIO/PWM | TBD |
| Inter-board UART | UART1 @ 460800 | TBD |
| UART | Pins | Connected To | Baud | > All TBD GPIO assignments are confirmed in `esp32/io/src/config.h`.
|------|------|-------------|------|
| USART1 | PA9/PA10 | Jetson Orin Nano Super | 115200 |
| USART2 | PA2/PA3 | Hoverboard ESC | 115200 |
| USART3 | PB10/PB11 | ELRS Receiver | 420000 (CRSF) |
| UART4 | — | Spare | — |
| UART5 | — | Spare | — |
### Motor/ESC ### CAN Bus
- 2× 8" pneumatic hub motors (36V, hoverboard type) | Node | CAN ID | Notes |
- Hoverboard ESC with FOC firmware |------|--------|-------|
- UART protocol: `{0xABCD, int16 speed, int16 steer, uint16 checksum}` at 115200 | VESC left motor | 68 (0x44) | FSESC 6.7 Pro Mini Dual |
- Speed range: -1000 to +1000 | VESC right motor | 56 (0x38) | FSESC 6.7 Pro Mini Dual |
| Orin → robot cmds | 0x3000x303 | drive / arm / PID / ESTOP |
| BALANCE → Orin telemetry | 0x4000x401 | attitude + battery + faults |
### Physical Dimensions (from `cad/dimensions.scad`) ### Physical Dimensions
| Part | Key Measurement | | Parameter | Value |
|------|----------------| |-----------|-------|
| FC mounting holes | 25.5mm spacing (NOT standard 30.5mm!) | | Robot (SAUL-TEE) | 870 × 510 × 550 mm, 23 kg |
| FC board size | ~36mm square | | Hub motor axle base OD | Ø16.11 mm (caliper-verified) |
| Hub motor body | Ø200mm (~8") | | Hub motor axle D-cut OD | Ø15.95 mm, 13.00 mm flat chord |
| Motor axle | Ø12mm, 45mm long | | Bearing seat collar | Ø37.8 mm |
| Jetson Orin Nano Super | 100×80×29mm, M2.5 holes at 86×58mm | | Tire | 10 × 2.125" pneumatic (Ø254 mm) |
| RealSense D435i | 90×25×25mm, 1/4-20 tripod mount | | ESP32-S3 BALANCE PCB | ~40×40 mm (TBD — caliper before machining) |
| RPLIDAR A1 | Ø70×41mm, 4× M2.5 on Ø67mm circle | | Orin carrier hole pattern | 58 × 49 mm M3 |
| Kill switch hole | Ø22mm panel mount |
| Battery pack | ~180×80×40mm |
| Hoverboard ESC | ~80×50×15mm |
| 2020 extrusion | 20mm square, M5 center bore |
| Frame width | ~350mm (axle to axle) |
| Frame height | ~500-550mm total |
| Target weight | <8kg (current estimate: 7.4kg) |
### 3D Printed Parts (16 files in `cad/`) ## Inter-Board Protocol
| Part | Material | Infill | **UART @ 460800 baud, 8N1.** Frame: `[0xAA][LEN][TYPE][PAYLOAD…][CRC8]`
|------|----------|--------|
| motor_mount_plate (350×150×6mm) | PETG | 80% |
| battery_shelf | PETG | 60% |
| esc_mount | PETG | 40% |
| jetson_shelf | PETG | 40% |
| sensor_tower_top | ASA | 80% |
| lidar_standoff (Ø80×80mm) | ASA | 40% |
| realsense_bracket | PETG | 60% |
| fc_mount (vibration isolated) | TPU+PETG | — |
| bumper front + rear (350×50×30mm) | TPU | 30% |
| handle | PETG | 80% |
| kill_switch_mount | PETG | 80% |
| tether_anchor | PETG | 100% |
| led_diffuser_ring (Ø120×15mm) | Clear PETG | 30% |
| esp32c3_mount | PETG | 40% |
## Firmware Architecture CRC polynomial: CRC-8/MAXIM (poly 0x31, init 0x00, RefIn/RefOut true).
### Critical Lessons Learned (DON'T REPEAT THESE) Authoritative message type definitions: `esp32/shared/protocol.h`
1. **SysTick_Handler with HAL_IncTick() is MANDATORY** — without it, HAL_Delay() and every HAL timeout hangs forever. This bricked us multiple times. ## Build & Flash
<<<<<<< HEAD
2. **DCache breaks SPI on ESP32** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
=======
2. **DCache breaks SPI on ESP32-S3** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
3. **`-(int)0 == 0`** — checking `if (-result)` to detect errors doesn't work when result is 0 (success and failure look the same). Always use explicit error codes.
4. **NEVER auto-run untested code on_boot** — we bricked the NSPanel 3x doing this. Test manually first.
5. **USB Serial (CH343) needs ReceivePacket() primed in CDC_Init** — without it, the OUT endpoint never starts listening. No data reception.
### DFU Reboot (Betaflight Method)
The firmware supports reboot-to-DFU via USB command:
1. Send `R` byte over USB Serial (CH343)
2. Firmware writes `0xDEADBEEF` to RTC backup register 0
3. `NVIC_SystemReset()` — clean hardware reset
4. On boot, `checkForBootloader()` (called after `HAL_Init()`) reads the magic
<<<<<<< HEAD
5. If magic found: clears it, remaps system memory, jumps to ESP32 BALANCE bootloader at `0x1FF00000`
=======
5. If magic found: clears it, remaps system memory, jumps to ESP32-S3 bootloader at `0x1FF00000`
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
6. Board appears as DFU device, ready for `dfu-util` flash
### Build & Flash
```bash ```bash
cd firmware/ # ESP32-S3 BALANCE
python3 -m platformio run # Build cd esp32/balance
dfu-util -a 0 -s 0x08000000:leave -D .pio/build/f722/firmware.bin # Flash pio run -t upload # Upload via USB (CH343 serial)
# ESP32-S3 IO
cd esp32/io
pio run -t upload # Upload via USB (JTAG/CDC)
``` ```
Dev machine: mbpm4 (seb@192.168.87.40), PlatformIO project at `~/Projects/saltylab-firmware/` ## Critical Lessons Learned
### Clock Configuration 1. **`-(int)0 == 0`** — checking `if (-result)` doesn't detect a zero error result. Use explicit error codes.
2. **NEVER auto-run untested firmware on boot** — we bricked hardware doing this. Test manually first.
3. **One variable at a time** — never change PID gains and speed limit in the same test session.
4. **QMI8658 data ready** — poll INT pin (GPIO3) or use interrupt; don't poll status register in a tight loop.
5. **CAN bus termination** — 120 Ω at each physical end of the bus. Missing termination = unreliable comms.
``` ## LED States (WS2812B on ESP32-IO)
HSE 8MHz → PLL (M=8, N=432, P=2, Q=9) → SYSCLK 216MHz
PLLSAI (N=384, P=8) → CLK48 48MHz (USB)
APB1 = HCLK/4 = 54MHz
APB2 = HCLK/2 = 108MHz
Fallback: HSI 16MHz if HSE fails (PLL M=16)
```
## Current Status & Known Issues
### Working
- USB Serial (CH343) serial streaming (50Hz JSON: `{"ax":...,"ay":...,"az":...,"gx":...,"gy":...,"gz":...}`)
- Clock config with HSE + HSI fallback
- Reboot-to-DFU via USB 'R' command
- LED status patterns (status.c)
- Web UI with WebSerial + Three.js 3D visualization
### Broken / In Progress
- **QMI8658-P SPI reads return all zeros** — was the original IMU target, but SPI communication completely non-functional despite correct pin config. May be dead silicon. Switched to MPU6000 as primary.
- **MPU6000 driver** — header exists but implementation needs completion
- **PID balance loop** — not yet implemented
- **Hoverboard ESC UART** — protocol defined, driver not written
- **ELRS CRSF receiver** — protocol defined, driver not written
- **Barometer (BMP280)** — I2C init hangs, disabled
### TODO (Priority Order)
1. Get MPU6000 streaming accel+gyro data
2. Implement complementary filter (pitch angle)
3. Write hoverboard ESC UART driver
4. Write PID balance loop with safety checks
5. Wire ELRS receiver, implement CRSF parser
6. Bench test (ESC disconnected, verify PID output)
7. First tethered balance test at 10% speed
8. Jetson UART integration
9. LED subsystem (ESP32-C3)
## Communication Protocols
### Jetson → FC (UART1, 50Hz)
```c
struct { uint8_t header=0xAA; int16_t speed; int16_t steer; uint8_t mode; uint8_t checksum; };
// mode: 0=idle, 1=balance, 2=follow, 3=RC
```
### FC → Hoverboard ESC (UART2, loop rate)
```c
struct { uint16_t start=0xABCD; int16_t speed; int16_t steer; uint16_t checksum; };
// speed/steer: -1000 to +1000
```
### FC → Jetson Telemetry (UART1 TX, 50Hz)
```
T:12.3,P:45,L:100,R:-80,S:3\n
// T=tilt°, P=PID output, L/R=motor commands, S=state (0-3)
```
### FC → USB Serial (CH343) (50Hz JSON)
```json
{"ax":123,"ay":-456,"az":16384,"gx":10,"gy":-5,"gz":3,"t":250,"p":0,"bt":0}
// Raw IMU values (int16), t=temp×10, p=pressure, bt=baro temp
```
## LED Subsystem (ESP32-C3)
ESP32-C3 eavesdrops on FC→Jetson telemetry (listen-only tap on UART1 TX). No extra FC UART needed.
| State | Pattern | Color | | State | Pattern | Color |
|-------|---------|-------| |-------|---------|-------|
| Disarmed | Slow breathe | White | | Disarmed | Slow breathe | White |
| Arming | Fast blink | Yellow | | Arming | Fast blink | Yellow |
| Armed idle | Solid | Green | | Armed | Solid | Green |
| Turning | Sweep direction | Orange | | Turning | Sweep direction | Orange |
| Braking | Flash rear | Red | | Braking | Flash rear | Red |
| Fault | Triple flash | Red | | Fault / ESTOP | Triple flash | Red |
| RC lost | Alternating flash | Red/Blue | | RC lost | Alternating flash | Red/Blue |
## Printing (Bambu Lab) ## Printing (Bambu Lab)
- **X1C** (192.168.87.190) — for structural PETG/ASA parts - **X1C** (192.168.87.190) — structural PETG/ASA parts
- **A1** (192.168.86.161) — for TPU bumpers and prototypes - **A1** (192.168.86.161) — TPU bumpers, prototypes
- LAN access codes and MQTT details in main workspace MEMORY.md
- STL export from OpenSCAD, slice in Bambu Studio - STL export from OpenSCAD, slice in Bambu Studio
## Rules for Agents ## Rules for Agents
1. **Read SALTYLAB.md fully** before making any design decisions 1. **Read `docs/SAUL-TEE-SYSTEM-REFERENCE.md`** fully before any design or firmware decision
2. **Never remove safety checks** from firmware — add more if needed 2. **Never remove safety checks** — add more if needed
3. **All measurements go in `cad/dimensions.scad`** — single source of truth 3. **All mechanical measurements go in `cad/dimensions.scad`** — single source of truth
4. **Test firmware on bench before any motor test** — ESC disconnected, verify outputs on serial 4. **Test firmware on bench first** — VESCs/BTS7960 disconnected, verify outputs on serial
5. **One variable at a time** — don't change PID and speed limit in the same test 5. **GPIO assignments live in `config.h`** — change there, not scattered in source
6. **Document what you change** — update this file if you add pins, change protocols, or discover hardware quirks 6. **Document hardware quirks here** — if you find a gotcha, add a "Critical Lesson Learned"
7. **Ask before wiring changes** — wrong connections can fry the FC ($50+ board) 7. **Ask before wiring changes** — wrong connections can fry ESP32-S3 boards

View File

@ -1,10 +1,6 @@
# Face LCD Animation System (Issue #507) # Face LCD Animation System (Issue #507)
<<<<<<< HEAD Implements expressive face animations on the ESP32-S3 BALANCE board LCD display (Waveshare Touch LCD 1.28, GC9A01 1.28" round 240×240) with 5 core emotions and smooth transitions.
Implements expressive face animations on an ESP32 LCD display with 5 core emotions and smooth transitions.
=======
Implements expressive face animations on an ESP32-S3 LCD display with 5 core emotions and smooth transitions.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Features ## Features
@ -86,13 +82,9 @@ STATUS → Echo current emotion + idle state
- Colors: Monochrome (1-bit) or RGB565 - Colors: Monochrome (1-bit) or RGB565
### Microcontroller ### Microcontroller
<<<<<<< HEAD - ESP32-S3 BALANCE (Waveshare Touch LCD 1.28)
- ESP32xx (ESP32 BALANCE) - Receives emotion commands from Orin via CAN (0x300 mode byte) or inter-board UART
======= - Clock: 240 MHz
- ESP32-S3xx (ESP32-S3 BALANCE)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- Available UART: USART3 (PB10=TX, PB11=RX)
- Clock: 216 MHz
## Animation Timing ## Animation Timing

View File

@ -1,6 +1,6 @@
# SAUL-TEE — Self-Balancing Wagon Robot 🔬 # SaltyLab — Self-Balancing Indoor Bot 🔬
Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md` Two-wheeled, self-balancing robot for indoor AI/SLAM experiments.
## ⚠️ SAFETY — TOP PRIORITY ## ⚠️ SAFETY — TOP PRIORITY
@ -11,7 +11,7 @@ Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REF
2. **Software tilt cutoff** — if pitch exceeds ±25° (not 30°), motors go to zero immediately. No retry, no recovery. Requires manual re-arm. 2. **Software tilt cutoff** — if pitch exceeds ±25° (not 30°), motors go to zero immediately. No retry, no recovery. Requires manual re-arm.
3. **Startup arming sequence** — motors NEVER spin on power-on. Requires deliberate arming: hold button for 3 seconds while robot is upright and stable. 3. **Startup arming sequence** — motors NEVER spin on power-on. Requires deliberate arming: hold button for 3 seconds while robot is upright and stable.
4. **Watchdog timeout** — if FC firmware hangs or crashes, hardware watchdog resets to safe state (motors off) within 50ms. 4. **Watchdog timeout** — if FC firmware hangs or crashes, hardware watchdog resets to safe state (motors off) within 50ms.
5. **Current limiting**hoverboard ESC max current set conservatively. Start low, increase gradually. 5. **Current limiting**VESC max current set conservatively. Start low, increase gradually.
6. **Tether during development** — ceiling rope/strap during ALL balance testing. No free-standing tests until PID is proven stable for 5+ minutes tethered. 6. **Tether during development** — ceiling rope/strap during ALL balance testing. No free-standing tests until PID is proven stable for 5+ minutes tethered.
7. **Speed limiting** — firmware hard cap on max speed. Start at 10% throttle, increase in 10% increments only after stable testing. 7. **Speed limiting** — firmware hard cap on max speed. Start at 10% throttle, increase in 10% increments only after stable testing.
8. **Remote kill** — Jetson can send emergency stop via UART. If Jetson disconnects (UART timeout >200ms), FC cuts motors automatically. 8. **Remote kill** — Jetson can send emergency stop via UART. If Jetson disconnects (UART timeout >200ms), FC cuts motors automatically.
@ -31,8 +31,9 @@ Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REF
| Part | Status | | Part | Status |
|------|--------| |------|--------|
| 2x 8" pneumatic hub motors (36 PSI) | ✅ Have | | 2x 8" pneumatic hub motors (36 PSI) | ✅ Have |
| 1x hoverboard ESC (FOC firmware) | ✅ Have | | 2x VESC FSESC 6.7 Pro Mini Dual (left ID 68, right ID 56) | ✅ Have |
| 1x Drone FC (ESP32-S3 + QMI8658) | ✅ Have — balance brain | | 1x ESP32-S3 BALANCE (Waveshare Touch LCD 1.28) | ⬜ Need — PID loop + CAN master |
| 1x ESP32-S3 IO (bare board) | ⬜ Need — RC / motor / sensor I/O |
| 1x Jetson Orin Nano Super + Noctua fan | ✅ Have | | 1x Jetson Orin Nano Super + Noctua fan | ✅ Have |
| 1x RealSense D435i | ✅ Have | | 1x RealSense D435i | ✅ Have |
| 1x RPLIDAR A1M8 | ✅ Have | | 1x RPLIDAR A1M8 | ✅ Have |
@ -47,21 +48,24 @@ Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REF
| 1x Arming button (momentary, with LED) | ⬜ Need | | 1x Arming button (momentary, with LED) | ⬜ Need |
| 1x Ceiling tether strap + carabiner | ⬜ Need | | 1x Ceiling tether strap + carabiner | ⬜ Need |
| 1x BetaFPV ELRS 2.4GHz 1W TX module | ✅ Have — RC control + kill switch | | 1x BetaFPV ELRS 2.4GHz 1W TX module | ✅ Have — RC control + kill switch |
| 1x ELRS receiver (matching) | ✅ Have — mounts on FC UART | | 1x ELRS receiver (matching) | ✅ Have — failover RC on ESP32-IO UART2 |
### ESP32-S3 BALANCE Board Details — Waveshare ESP32-S3 Touch LCD 1.28 ### ESP32-S3 BALANCE (Waveshare Touch LCD 1.28)
- **MCU:** ESP32-S3RET6 (Xtensa LX7 dual-core, 240MHz, 8MB Flash, 512KB SRAM) - **MCU:** ESP32-S3, dual-core 240 MHz, 8MB flash, 8MB PSRAM
- **IMU:** QMI8658 (6-axis, 32kHz gyro, ultra-low noise, SPI) ← the good one! - **Display:** 1.28" round GC9A01 240×240 LCD (face animations)
- **Display:** 1.28" round LCD (GC9A01 driver, 240x240) - **IMU:** QMI8658 6-axis (I2C-0 SDA=GPIO6, SCL=GPIO7) — onboard
- **DFU mode:** Hold BOOT button while plugging USB - **CAN:** SN65HVD230 external transceiver → 500 kbps CAN to VESCs
- **Firmware:** Custom balance firmware (ESP-IDF / Arduino-ESP32) - **USB:** CH343G bridge (UART0 GPIO43/44) — programming + debug
- **USB:** USB Serial via CH343 chip - **Firmware:** `esp32/balance/` (PlatformIO, Arduino framework)
- **UART assignments:** - **Role:** PID / stability loop, VESC CAN master, inter-board UART to IO board
- UART0 → USB Serial (CH343) → debug/flash
- UART1 → Jetson Orin Nano Super ### ESP32-S3 IO (bare board)
- UART2 → Hoverboard ESC - **USB:** Built-in JTAG/USB-CDC — programming + debug
- UART3 → ELRS receiver - **RC:** TBS Crossfire on UART0 (GPIO43/44), ELRS failover on UART2
- UART4/5 → spare - **Drive:** 4× BTS7960 H-bridge drivers for hub motors (GPIO TBD)
- **Sensors:** NFC, barometer, ToF distance (shared I2C, GPIO TBD)
- **Outputs:** WS2812B LEDs (RMT), horn, headlight, fan, buzzer
- **Firmware:** `esp32/io/` (PlatformIO, Arduino framework)
## Architecture ## Architecture
@ -73,60 +77,101 @@ Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REF
│ RealSense │ ← Forward-facing depth+RGB │ RealSense │ ← Forward-facing depth+RGB
│ D435i │ │ D435i │
├──────────────┤ ├──────────────┤
│ Jetson Orin Nano Super │ ← AI brain: navigation, person tracking │ Jetson Orin │ ← AI brain: ROS2, SLAM, Nav2
│ Sends velocity commands via UART Nano Super │ Sends CAN cmds 0x3000x303
├──────────────┤ ├──────────────┤
│ Drone FC │ ← Balance brain: IMU + PID @ 8kHz │ ESP32-S3 │ ← Balance brain: QMI8658 IMU + PID
│ F745+MPU6000 │ Custom firmware, UART out to ESC │ BALANCE │ CAN master to VESCs (SN65HVD230)
├──────────────┤
│ ESP32-S3 IO │ ← RC (CRSF/ELRS), sensors, LEDs
├──────────────┤ ├──────────────┤
│ Battery 36V │ │ Battery 36V │
│ + DC-DCs │ │ + DC-DCs │
├──────┬───────┤ ├──────┬───────┤
┌─────┤ ESC (FOC) ├─────┐ ┌─────┤ VESC Left ├─────┐
│ │ Hoverboard │ │ │ │ (ID 68) │ │
│ │ VESC Right │ │
│ │ (ID 56) │ │
│ └──────────────┘ │ │ └──────────────┘ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
8" │ │ 8" Hub │ │ Hub
LEFT│ │RIGHT motor│ │motor
└─────┘ └─────┘ └─────┘ └─────┘
``` ```
## Self-Balancing Control — ESP32-S3 BALANCE Board ## Self-Balancing Control — Custom Firmware on ESP32-S3 BALANCE
> For full system architecture, firmware details, and protocol specs, see
> **docs/SAUL-TEE-SYSTEM-REFERENCE.md**
The balance controller runs on the Waveshare ESP32-S3 Touch LCD 1.28 board
(ESP32-S3 BALANCE). It reads the onboard QMI8658 IMU at 8kHz, runs a PID
balance loop, and drives the hoverboard ESC via UART. Jetson Orin Nano Super
sends velocity commands over UART1. ELRS receiver on UART3 provides RC
override and kill-switch capability.
The legacy STM32 firmware (Mamba F722S era) has been archived to
=======
The legacy STM32 firmware (STM32 era) has been archived to
`legacy/stm32/` and is no longer built or deployed.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## LED Subsystem (ESP32-C3)
### Architecture ### Architecture
The ESP32-C3 eavesdrops on the FC→Jetson telemetry UART line (listen-only, one wire).
No extra UART needed on the FC — zero firmware change.
``` ```
FC UART1 TX ──┬──→ Jetson RX Jetson Orin (CAN 0x3000x303)
└──→ ESP32-C3 RX (listen-only, same wire) │ - Drive cmd: speed + steer
│ - Arm/disarm, PID tune, ESTOP
└──→ WS2812B strip (via RMT peripheral)
ESP32-S3 BALANCE (PlatformIO / Arduino)
│ - Reads QMI8658 IMU @ I2C (GPIO6/7)
│ - Runs PID balance loop
│ - Mixes balance correction + Orin velocity cmd
│ - Sends VESC CAN commands (SN65HVD230, 500 kbps)
│ - Inter-board UART @ 460800 → ESP32-S3 IO
VESC Left (CAN ID 68) VESC Right (CAN ID 56)
│ │
FL + RL hub motors FR + RR hub motors
``` ```
### Telemetry Format (already sent by FC at 50Hz) ### Wiring
``` ```
T:12.3,P:45,L:100,R:-80,S:3\n Jetson Orin CANable 2.0
^-- State byte: 0=disarmed, 1=arming, 2=armed, 3=fault ──────────── ──────────
USB-A ──→ USB-B
CANH ──→ CAN bus CANH
CANL ──→ CAN bus CANL
ESP32-S3 BALANCE SN65HVD230 transceiver
──────────────── ──────────────────────
CAN TX (GPIO TBD) ──→ D pin
CAN RX (GPIO TBD) ←── R pin
CANH ──→ CAN bus CANH
CANL ──→ CAN bus CANL
ESP32-S3 BALANCE ESP32-S3 IO
──────────────── ───────────
UART1 TX (TBD) ──→ UART1 RX (TBD)
UART1 RX (TBD) ←── UART1 TX (TBD)
GND ──→ GND
TBS Crossfire RX ESP32-S3 IO
──────────────── ───────────
TX ──→ GPIO44 (UART0 RX)
RX ←── GPIO43 (UART0 TX)
GND ──→ GND
5V ←── 5V
```
### PID Tuning
| Param | Starting Value | Notes |
|-------|---------------|-------|
| Kp | 30-50 | Main balance response |
| Ki | 0.5-2 | Drift correction |
| Kd | 0.5-2 | Damping oscillation |
| Loop rate | 1 kHz | QMI8658 data-ready driven (GPIO3 INT) |
| Max tilt | ±25° | Beyond this = cut motors, require re-arm |
| CAN_WATCHDOG_MS | 500 | Drop to RC-only if Orin CAN heartbeat lost |
| max_speed_limit | 10% | Start at 10%, increase after stable testing |
| SPEED_TO_ANGLE_FACTOR | 0.01-0.05 | How much lean per speed unit |
## LED Subsystem (ESP32-S3 IO)
### Architecture
WS2812B LEDs are driven directly by the ESP32-S3 IO board via its RMT peripheral.
The IO board receives robot state over inter-board UART from ESP32-S3 BALANCE.
```
ESP32-S3 BALANCE ──UART 460800──→ ESP32-S3 IO
└──RMT──→ WS2812B strip
``` ```
ESP32-C3 parses the `S:` field and `L:/R:` for turn detection.
### LED Patterns ### LED Patterns
| State | Pattern | Color | | State | Pattern | Color |
@ -140,25 +185,18 @@ ESP32-C3 parses the `S:` field and `L:/R:` for turn detection.
| Fault | Triple flash | Red | | Fault | Triple flash | Red |
| RC signal lost | Alternating flash | Red/Blue | | RC signal lost | Alternating flash | Red/Blue |
### Turn/Brake Detection (on ESP32-C3)
```
if (L - R > threshold) → turning right
if (R - L > threshold) → turning left
if (L < -threshold && R < -threshold) braking
```
### Wiring ### Wiring
``` ```
FC UART1 TX pin ──→ ESP32-C3 GPIO RX (e.g. GPIO20) ESP32-S3 IO RMT GPIO (TBD) ──→ WS2812B data in
ESP32-C3 GPIO8 ──→ WS2812B data in 5V bus ──→ WS2812B 5V + ESP32-S3 IO VCC
ESC 5V BEC ──→ ESP32-C3 5V + WS2812B 5V GND ──→ Common ground
GND ──→ Common ground
``` ```
### Dev Tools ### Dev Tools
- **Flashing:** ESP32-S3CubeProgrammer via USB (DFU mode) or SWD - **Flashing BALANCE:** `pio run -t upload` in `esp32/balance/` via CH343G USB
- **IDE:** PlatformIO + ESP-IDF, or ESP32-S3CubeIDE - **Flashing IO:** `pio run -t upload` in `esp32/io/` via JTAG/USB-CDC
- **Debug:** SWD via ST-Link (or use FC's USB as virtual COM for printf debug) - **IDE:** PlatformIO + Arduino framework (ESP32)
- **Debug:** USB serial monitor (`pio device monitor`), logic analyzer on UART/CAN
## Physical Design ## Physical Design
@ -173,7 +211,7 @@ GND ──→ Common ground
├───────────┤ ├─────────────────┤ ├───────────┤ ├─────────────────┤
│ Jetson │ ~300mm │ [Jetson] │ │ Jetson │ ~300mm │ [Jetson] │
├───────────┤ ├─────────────────┤ ├───────────┤ ├─────────────────┤
Drone FC │ ~200mm │ [Drone FC] ESP32-S3 │ ~200mm │ [BALANCE]
├───────────┤ ├─────────────────┤ ├───────────┤ ├─────────────────┤
│ Battery │ ~100mm │ [Battery] │ │ Battery │ ~100mm │ [Battery] │
│ + ESC │ LOW! │ [ESC+DCDC] │ │ + ESC │ LOW! │ [ESC+DCDC] │
@ -213,7 +251,8 @@ GND ──→ Common ground
| Sensor tower top | 120×120×10 | ASA 80% | 1 | | Sensor tower top | 120×120×10 | ASA 80% | 1 |
| LIDAR standoff | Ø80×80 | ASA 40% | 1 | | LIDAR standoff | Ø80×80 | ASA 40% | 1 |
| RealSense bracket | 100×50×40 | PETG 60% | 1 | | RealSense bracket | 100×50×40 | PETG 60% | 1 |
| FC mount (vibration isolated) | 30×30×15 | TPU+PETG | 1 | | MCU mount — ESP32-S3 BALANCE | TBD×TBD×15 | TPU+PETG | 1 |
| MCU mount — ESP32-S3 IO | TBD×TBD×15 | PETG | 1 |
| Bumper front | 350×50×30 | TPU 30% | 1 | | Bumper front | 350×50×30 | TPU 30% | 1 |
| Bumper rear | 350×50×30 | TPU 30% | 1 | | Bumper rear | 350×50×30 | TPU 30% | 1 |
| Handle (for carrying) | 150×30×30 | PETG 80% | 1 | | Handle (for carrying) | 150×30×30 | PETG 80% | 1 |
@ -225,14 +264,14 @@ GND ──→ Common ground
## Software Stack ## Software Stack
### Jetson Orin Nano Super ### Jetson Orin Nano Super
- **OS:** JetPack 4.6.1 (Ubuntu 18.04) - **OS:** JetPack 6.x (Ubuntu 22.04)
- **ROS2 Humble** (or Foxy) for: - **ROS2 Humble** for:
- `nav2` — navigation stack - `nav2` — navigation stack
- `slam_toolbox` — 2D SLAM from LIDAR - `slam_toolbox` — 2D SLAM from LIDAR
- `realsense-ros` — depth camera - `realsense-ros` — depth camera
- `rplidar_ros` — LIDAR driver - `rplidar_ros` — LIDAR driver
- **Person following:** SSD-MobileNet-v2 via TensorRT (~20 FPS) - **Person following:** SSD-MobileNet-v2 via TensorRT (~30+ FPS)
- **Balance commands:** ROS topic → UART bridge to drone FC - **Balance commands:** ROS topic → CAN bus → ESP32-S3 BALANCE (CANable 2.0, can0, 500 kbps)
### Modes ### Modes
1. **Idle** — self-balancing in place, waiting for command 1. **Idle** — self-balancing in place, waiting for command
@ -251,33 +290,34 @@ GND ──→ Common ground
- [ ] Install hardware kill switch inline with 36V battery (NC — press to kill) - [ ] Install hardware kill switch inline with 36V battery (NC — press to kill)
- [ ] Set up ceiling tether point above test area (rated for >15kg) - [ ] Set up ceiling tether point above test area (rated for >15kg)
- [ ] Clear test area: 3m radius, no loose items, shoes on - [ ] Clear test area: 3m radius, no loose items, shoes on
- [ ] Set up PlatformIO project for ESP32-S3 (ESP-IDF) - [ ] Set up PlatformIO projects for ESP32-S3 BALANCE + IO (`esp32/balance/`, `esp32/io/`)
- [ ] Write QMI8658 SPI driver (read gyro+accel, complementary filter) - [ ] Confirm QMI8658 I2C comms on GPIO6/7 (INT on GPIO3); verify IMU data on serial
- [ ] Write PID balance loop with ALL safety checks: - [ ] Write PID balance loop with ALL safety checks:
- ±25° tilt cutoff → disarm, require manual re-arm - ±25° tilt cutoff → disarm, require manual re-arm
- Watchdog timer (50ms hardware WDT) - CAN watchdog 500 ms (drop to RC-only if Orin silent)
- Speed limit at 10% (max_speed_limit = 100) - Speed limit at 10%
- Arming sequence (3s hold while upright) - Arming sequence (deliberate ARM command required on power-on)
- [ ] Write hoverboard ESC UART output (speed+steer protocol) - [ ] Write VESC CAN commands (SN65HVD230 transceiver, 500 kbps, IDs 68/56)
- [ ] Flash firmware via USB DFU (boot0 jumper on FC) - [ ] Flash BALANCE via CH343G USB: `cd esp32/balance && pio run -t upload`
- [ ] Write ELRS CRSF receiver driver (UART3, parse channels + arm switch) - [ ] Write TBS Crossfire CRSF driver on IO board (UART0 GPIO43/44, 420000 baud)
- [ ] Bind ELRS TX ↔ RX, verify channel data on serial monitor - [ ] Bind TBS TX ↔ RX, verify channel data on IO board serial monitor
- [ ] Map radio: CH1=steer, CH2=speed, CH5=arm/disarm switch - [ ] Map radio: CH1=steer, CH2=speed, CH5=arm/disarm, CH6=mode
- [ ] **Bench test first** — FC powered but ESC disconnected, verify IMU reads + PID output + RC channels on serial monitor - [ ] Flash IO via JTAG/USB-CDC: `cd esp32/io && pio run -t upload`
- [ ] Wire FC UART2 → hoverboard ESC UART - [ ] **Bench test first** — BALANCE powered but VESCs disconnected; verify IMU + PID output + RC channels on serial; no motors spin
- [ ] Build minimal frame: motor plate + battery + ESC + FC - [ ] Wire BALANCE CAN TX/RX → SN65HVD230 → CAN bus → VESCs
- [ ] Power FC from ESC 5V BEC - [ ] Build minimal frame: motor plate + battery + VESCs + ESP32-S3 boards
- [ ] Power ESP32s from 5V DC-DC
- [ ] **First balance test — TETHERED, kill switch in hand, 10% speed limit** - [ ] **First balance test — TETHERED, kill switch in hand, 10% speed limit**
- [ ] Tune PID at 10% speed until stable tethered for 5+ minutes - [ ] Tune PID at 10% speed until stable tethered for 5+ minutes
- [ ] Gradually increase speed limit (10% increments, 5 min stable each) - [ ] Gradually increase speed limit (10% increments, 5 min stable each)
### Phase 2: Brain (Week 2) ### Phase 2: Brain (Week 2)
- [ ] Mount Jetson + power (DC-DC 5V) - [ ] Mount Jetson Orin Nano Super + power (DC-DC 5V via USB-C PD)
- [ ] Set up JetPack + ROS2 - [ ] Set up JetPack + ROS2
- [ ] Add Jetson UART RX to FC firmware (receive speed+steer commands) - [ ] Bring up CANable 2.0 on Orin: `ip link set can0 up type can bitrate 500000`
- [ ] Wire Jetson UART1 → FC UART1 - [ ] Send drive CAN frames (0x300) from Orin → BALANCE firmware receives + acts
- [ ] Python serial bridge: send speed+steer, read telemetry - [ ] ROS2 node: subscribe to `/cmd_vel`, publish CAN drive frames
- [ ] Test: keyboard teleoperation while balancing - [ ] Test: keyboard teleoperation via ROS2 while balancing
### Phase 3: Senses (Week 3) ### Phase 3: Senses (Week 3)
- [ ] Mount RealSense + RPLIDAR - [ ] Mount RealSense + RPLIDAR
@ -287,10 +327,9 @@ GND ──→ Common ground
### Phase 4: Polish (Week 4) ### Phase 4: Polish (Week 4)
- [ ] Print proper enclosures, bumpers, diffuser ring - [ ] Print proper enclosures, bumpers, diffuser ring
- [ ] Wire ESP32-C3 to FC telemetry TX line (listen-only tap) - [ ] Implement WS2812B LED patterns in ESP32-S3 IO firmware (RMT, state from inter-board UART)
- [ ] Flash ESP32-C3: parse telemetry, drive WS2812B via RMT
- [ ] Mount LED strip around frame with diffuser - [ ] Mount LED strip around frame with diffuser
- [ ] Test all LED patterns: disarmed/arming/armed/turning/fault - [ ] Test all LED patterns: disarmed/arming/armed/turning/fault/RC-lost
- [ ] Speaker for audio feedback - [ ] Speaker / buzzer audio feedback (IO board GPIO)
- [ ] WiFi status dashboard (ESP32-C3 can serve this too) - [ ] WiFi status dashboard (serve from Orin or IO board AP)
- [ ] Emergency stop button - [ ] Emergency stop button wired to IO board GPIO → ESTOP CAN frame 0x303

View File

@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>GEPRC GEP-F722-45A AIO — Board Layout (Legacy / Archived)</title> <title>ESP32-S3 BALANCE — Waveshare Touch LCD 1.28 Pinout</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #eee; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; padding: 20px; } body { background: #1a1a2e; color: #eee; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; padding: 20px; }
@ -10,274 +10,219 @@ h1 { color: #e94560; margin-bottom: 5px; font-size: 1.4em; }
.subtitle { color: #888; margin-bottom: 20px; font-size: 0.85em; } .subtitle { color: #888; margin-bottom: 20px; font-size: 0.85em; }
.container { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; justify-content: center; } .container { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; justify-content: center; }
.board-wrap { position: relative; } .board-wrap { position: relative; }
.board { width: 400px; height: 340px; background: #1a472a; border: 3px solid #333; border-radius: 8px; position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.5); } .board { width: 400px; height: 380px; background: #1a472a; border: 3px solid #333; border-radius: 50%; position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
.board::before { content: 'GEPRC GEP-F722-45A AIO'; position: absolute; top: 8px; left: 50%; transform: translateX(-50%); color: #fff3; font-size: 10px; letter-spacing: 2px; } .board::before { content: 'Waveshare Touch LCD 1.28'; position: absolute; top: 30px; left: 50%; transform: translateX(-50%); color: #fff3; font-size: 9px; letter-spacing: 1px; white-space: nowrap; }
/* Mounting holes */ /* LCD circle */
.mount { width: 10px; height: 10px; background: #111; border: 2px solid #555; border-radius: 50%; position: absolute; } .lcd { width: 180px; height: 180px; background: #111; border: 3px solid #444; border-radius: 50%; position: absolute; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #64B5F6; text-align: center; line-height: 1.6; }
.mount.tl { top: 15px; left: 15px; } .lcd-inner { text-align: center; }
.mount.tr { top: 15px; right: 15px; }
.mount.bl { bottom: 15px; left: 15px; }
.mount.br { bottom: 15px; right: 15px; }
/* MCU */ /* MCU chip */
.mcu { width: 80px; height: 80px; background: #222; border: 1px solid #555; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; font-size: 9px; color: #aaa; text-align: center; line-height: 1.3; } .mcu-label { position: absolute; top: 95px; left: 50%; transform: translateX(-50%); font-size: 8px; color: #aaa; text-align: center; white-space: nowrap; }
.mcu .dot { width: 5px; height: 5px; background: #666; border-radius: 50%; position: absolute; top: 4px; left: 4px; }
/* IMU */
.imu { width: 32px; height: 32px; background: #333; border: 1px solid #e94560; position: absolute; top: 85px; left: 60px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: #e94560; }
.imu::after { content: 'CW90°'; position: absolute; bottom: -14px; color: #e94560; font-size: 8px; white-space: nowrap; }
/* Arrow showing CW90 rotation */
.rotation-arrow { position: absolute; top: 72px; left: 55px; color: #e94560; font-size: 18px; }
/* Pads */
.pad { position: absolute; display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer; }
.pad .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 7px; font-weight: bold; }
.pad:hover .label { color: #fff; }
.pad .label { transition: color 0.2s; }
.pad .sublabel { font-size: 8px; color: #888; }
/* UART colors */
.uart1 .dot { background: #2196F3; border-color: #64B5F6; }
.uart2 .dot { background: #FF9800; border-color: #FFB74D; }
.uart3 .dot { background: #9C27B0; border-color: #CE93D8; }
.uart4 .dot { background: #4CAF50; border-color: #81C784; }
.uart5 .dot { background: #F44336; border-color: #EF9A9A; }
/* Component dots */ /* Component dots */
.comp { position: absolute; font-size: 9px; display: flex; align-items: center; gap: 4px; } .comp { position: absolute; font-size: 9px; color: #ccc; }
.comp .icon { width: 10px; height: 10px; border-radius: 2px; }
/* LED */
.led-blue { position: absolute; width: 8px; height: 8px; background: #2196F3; border-radius: 50%; box-shadow: 0 0 8px #2196F3; top: 45px; right: 50px; }
.led-label { position: absolute; top: 36px; right: 30px; font-size: 8px; color: #64B5F6; }
/* Boot button */
.boot-btn { position: absolute; width: 16px; height: 10px; background: #b8860b; border: 1px solid #daa520; border-radius: 2px; bottom: 45px; right: 40px; }
.boot-label { position: absolute; bottom: 32px; right: 30px; font-size: 8px; color: #daa520; }
/* USB */ /* USB */
.usb { position: absolute; width: 30px; height: 14px; background: #444; border: 2px solid #777; border-radius: 3px; bottom: -3px; left: 50%; transform: translateX(-50%); } .usb { position: absolute; width: 36px; height: 14px; background: #444; border: 2px solid #777; border-radius: 3px; bottom: 25px; left: 50%; transform: translateX(-50%); }
.usb-label { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); font-size: 8px; color: #999; } .usb-label { position: absolute; bottom: 44px; left: 50%; transform: translateX(-50%); font-size: 8px; color: #999; white-space: nowrap; }
/* Connector pads along edges */ /* Pin rows */
/* Bottom row: T1 R1 T3 R3 */ .pin-row { position: absolute; display: flex; flex-direction: column; gap: 4px; }
.pad-t1 { bottom: 20px; left: 40px; } .pin { display: flex; align-items: center; gap: 4px; font-size: 10px; }
.pad-r1 { bottom: 20px; left: 80px; } .pin .dot { width: 10px; height: 10px; border-radius: 50%; border: 2px solid; flex-shrink: 0; }
.pad-t3 { bottom: 20px; left: 140px; } .pin .name { min-width: 70px; }
.pad-r3 { bottom: 20px; left: 180px; } .pin .sublabel { font-size: 8px; color: #888; }
/* Right side: T2 R2 */ /* Left side pins */
.pad-t2 { right: 20px; top: 80px; flex-direction: row-reverse; } .pins-left { left: 10px; top: 60px; }
.pad-r2 { right: 20px; top: 110px; flex-direction: row-reverse; } .pins-left .pin { flex-direction: row; }
/* Top row: T4 R4 T5 R5 */ /* Right side pins */
.pad-t4 { top: 30px; left: 40px; } .pins-right { right: 10px; top: 60px; }
.pad-r4 { top: 30px; left: 80px; } .pins-right .pin { flex-direction: row-reverse; text-align: right; }
.pad-t5 { top: 30px; right: 100px; flex-direction: row-reverse; }
.pad-r5 { top: 30px; right: 55px; flex-direction: row-reverse; }
/* ESC pads (motor outputs - not used) */ /* Colors by function */
.esc-pads { position: absolute; left: 20px; top: 140px; } .imu .dot { background: #e94560; border-color: #ff6b81; }
.esc-pads .esc-label { font-size: 8px; color: #555; } .can .dot { background: #FF9800; border-color: #FFB74D; }
.uart .dot { background: #2196F3; border-color: #64B5F6; }
.spi .dot { background: #9C27B0; border-color: #CE93D8; }
.pwr .dot { background: #4CAF50; border-color: #81C784; }
.io .dot { background: #607D8B; border-color: #90A4AE; }
/* Legend */ /* Legend */
.legend { background: #16213e; padding: 15px 20px; border-radius: 8px; min-width: 280px; } .legend { background: #16213e; padding: 15px 20px; border-radius: 8px; min-width: 290px; }
.legend h2 { color: #e94560; font-size: 1.1em; margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 5px; } .legend h2 { color: #e94560; font-size: 1.1em; margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 5px; }
.legend-item { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; } .legend-item { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; }
.legend-item .swatch { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; } .legend-item .swatch { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
.legend-item .arrow { color: #888; font-size: 10px; }
.legend-section { margin-top: 12px; padding-top: 8px; border-top: 1px solid #333; } .legend-section { margin-top: 12px; padding-top: 8px; border-top: 1px solid #333; }
.legend-section h3 { font-size: 0.9em; color: #888; margin-bottom: 6px; } .legend-section h3 { font-size: 0.9em; color: #888; margin-bottom: 6px; }
/* Orientation guide */
.orient { margin-top: 20px; background: #16213e; padding: 15px 20px; border-radius: 8px; width: 100%; max-width: 710px; }
.orient h2 { color: #4CAF50; font-size: 1.1em; margin-bottom: 10px; }
.orient-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.orient-item { font-size: 12px; padding: 6px 10px; background: #1a1a2e; border-radius: 4px; }
.orient-item .dir { color: #4CAF50; font-weight: bold; }
/* Axis overlay */
.axis { position: absolute; }
.axis-x { top: 50%; right: -60px; color: #F44336; font-size: 12px; font-weight: bold; }
.axis-y { bottom: -30px; left: 50%; transform: translateX(-50%); color: #4CAF50; font-size: 12px; font-weight: bold; }
.axis-arrow-x { position: absolute; top: 50%; right: -45px; transform: translateY(-50%); width: 30px; height: 2px; background: #F44336; }
.axis-arrow-x::after { content: '▶'; position: absolute; right: -12px; top: -8px; color: #F44336; }
.axis-arrow-y { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); width: 2px; height: 20px; background: #4CAF50; }
.axis-arrow-y::after { content: '▼'; position: absolute; bottom: -14px; left: -5px; color: #4CAF50; }
.note { margin-top: 15px; color: #888; font-size: 11px; text-align: center; max-width: 710px; } .note { margin-top: 15px; color: #888; font-size: 11px; text-align: center; max-width: 710px; }
.note em { color: #e94560; font-style: normal; } .note em { color: #e94560; font-style: normal; }
</style> </style>
</head> </head>
<body> <body>
<<<<<<< HEAD <h1>🤖 ESP32-S3 BALANCE — Waveshare Touch LCD 1.28</h1>
<h1>🤖 GEPRC GEP-F722-45A AIO — SaltyLab Pinout (Legacy / Archived)</h1> <p class="subtitle">ESP32-S3 240 MHz | QMI8658 IMU | GC9A01 1.28″ LCD | CH343G USB-UART | SN65HVD230 CAN</p>
<p class="subtitle">ESP32RET6 + ICM-42688-P | Betaflight target: GEPR-GEPRC_F722_AIO</p>
=======
<h1>🤖 GEPRC GEP-F722-45A AIO — SaltyLab Pinout</h1>
<p class="subtitle">ESP32-S3RET6 + ICM-42688-P | Betaflight target: GEPR-GEPRC_F722_AIO</p>
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
<div class="container"> <div class="container">
<div class="board-wrap"> <div class="board-wrap">
<div class="board"> <div class="board">
<!-- Mounting holes --> <!-- LCD circle -->
<div class="mount tl"></div> <div class="lcd">
<div class="mount tr"></div> <div class="lcd-inner">GC9A01<br>1.28″ round<br>240×240<br>SPI</div>
<div class="mount bl"></div> </div>
<div class="mount br"></div> <div class="mcu-label">ESP32-S3<br>240 MHz / 8MB</div>
<!-- MCU --> <!-- USB CH343G -->
<<<<<<< HEAD <div class="usb-label">USB-A CH343G (UART0 debug/flash)</div>
<div class="mcu"><div class="dot"></div>ESP32<br>(legacy:<br>F722RET6)</div>
=======
<div class="mcu"><div class="dot"></div>ESP32-S3<br>F722RET6<br>216MHz</div>
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
<!-- IMU -->
<div class="imu">ICM<br>42688</div>
<div class="rotation-arrow"></div>
<!-- LED -->
<div class="led-blue"></div>
<div class="led-label">LED PC4</div>
<!-- Boot button -->
<div class="boot-btn"></div>
<div class="boot-label">BOOT 🟡</div>
<!-- USB -->
<div class="usb"></div> <div class="usb"></div>
<div class="usb-label">USB-C (DFU)</div>
<!-- UART Pads - Bottom --> <!-- Left-side pins (onboard fixed) -->
<div class="pad pad-t1 uart1"> <div class="pin-row pins-left">
<div class="dot">T</div> <div class="pin imu">
<span class="label">T1<br><span class="sublabel">PA9</span></span> <div class="dot"></div>
</div> <span class="name">GPIO6</span>
<div class="pad pad-r1 uart1"> <span class="sublabel">IMU SDA (I2C-0)</span>
<div class="dot">R</div> </div>
<span class="label">R1<br><span class="sublabel">PA10</span></span> <div class="pin imu">
</div> <div class="dot"></div>
<div class="pad pad-t3 uart3"> <span class="name">GPIO7</span>
<div class="dot">T</div> <span class="sublabel">IMU SCL (I2C-0)</span>
<span class="label">T3<br><span class="sublabel">PB10</span></span> </div>
</div> <div class="pin imu">
<div class="pad pad-r3 uart3"> <div class="dot"></div>
<div class="dot">R</div> <span class="name">GPIO3</span>
<span class="label">R3<br><span class="sublabel">PB11</span></span> <span class="sublabel">QMI8658 INT</span>
</div>
<div class="pin uart">
<div class="dot"></div>
<span class="name">GPIO43</span>
<span class="sublabel">UART0 TX (CH343G)</span>
</div>
<div class="pin uart">
<div class="dot"></div>
<span class="name">GPIO44</span>
<span class="sublabel">UART0 RX (CH343G)</span>
</div>
<div class="pin uart">
<div class="dot"></div>
<span class="name">UART1 TX</span>
<span class="sublabel">Inter-board → IO (TBD)</span>
</div>
<div class="pin uart">
<div class="dot"></div>
<span class="name">UART1 RX</span>
<span class="sublabel">Inter-board ← IO (TBD)</span>
</div>
</div> </div>
<!-- UART Pads - Right --> <!-- Right-side pins (external) -->
<div class="pad pad-t2 uart2"> <div class="pin-row pins-right">
<span class="label">T2<br><span class="sublabel">PA2</span></span> <div class="pin can">
<div class="dot">T</div> <div class="dot"></div>
<span class="name">CAN TX</span>
<span class="sublabel">→ SN65HVD230 D (TBD)</span>
</div>
<div class="pin can">
<div class="dot"></div>
<span class="name">CAN RX</span>
<span class="sublabel">← SN65HVD230 R (TBD)</span>
</div>
<div class="pin spi">
<div class="dot"></div>
<span class="name">LCD SPI</span>
<span class="sublabel">GC9A01 (onboard)</span>
</div>
<div class="pin pwr">
<div class="dot"></div>
<span class="name">5V</span>
<span class="sublabel">USB / ext 5V in</span>
</div>
<div class="pin pwr">
<div class="dot"></div>
<span class="name">3.3V</span>
<span class="sublabel">LDO out</span>
</div>
<div class="pin pwr">
<div class="dot"></div>
<span class="name">GND</span>
<span class="sublabel">Common ground</span>
</div>
</div> </div>
<div class="pad pad-r2 uart2">
<span class="label">R2<br><span class="sublabel">PA3</span></span>
<div class="dot">R</div>
</div>
<!-- UART Pads - Top -->
<div class="pad pad-t4 uart4">
<div class="dot">T</div>
<span class="label">T4<br><span class="sublabel">PC10</span></span>
</div>
<div class="pad pad-r4 uart4">
<div class="dot">R</div>
<span class="label">R4<br><span class="sublabel">PC11</span></span>
</div>
<div class="pad pad-t5 uart5">
<span class="label">T5<br><span class="sublabel">PC12</span></span>
<div class="dot">T</div>
</div>
<div class="pad pad-r5 uart5">
<span class="label">R5<br><span class="sublabel">PD2</span></span>
<div class="dot">R</div>
</div>
<!-- ESC motor pads label -->
<div class="esc-pads">
<div class="esc-label">M1-M4 (unused)<br>PC6-PC9</div>
</div>
<!-- Board axes -->
<div class="axis-arrow-x"></div>
<div class="axis axis-x">X →<br><span style="font-size:9px;color:#888">board right</span></div>
<div class="axis-arrow-y"></div>
<div class="axis axis-y">Y ↓ (board forward = tilt axis)</div>
</div> </div>
</div> </div>
<div class="legend"> <div class="legend">
<h2>🔌 UART Assignments</h2> <h2>📌 Pin Assignments</h2>
<div class="legend-item"> <div class="legend-item">
<div class="swatch" style="background:#2196F3"></div> <div class="swatch" style="background:#e94560"></div>
<span><b>USART1</b> T1/R1 → Jetson Orin Nano Super</span> <span><b>IMU (QMI8658)</b> — I2C-0 SDA=GPIO6, SCL=GPIO7, INT=GPIO3</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="swatch" style="background:#FF9800"></div> <div class="swatch" style="background:#FF9800"></div>
<span><b>USART2</b> T2 → Hoverboard ESC (TX only)</span> <span><b>CAN (SN65HVD230)</b> — TX/RX TBD; confirm in <code>esp32/balance/src/config.h</code></span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#2196F3"></div>
<span><b>UART0</b> GPIO43/44 — CH343G USB bridge (debug + flash)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#2196F3"></div>
<span><b>UART1</b> TBD — Inter-board @ 460800 baud → ESP32-S3 IO</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="swatch" style="background:#9C27B0"></div> <div class="swatch" style="background:#9C27B0"></div>
<span><b>I2C2</b> T3/R3 → Baro/Mag (reserved)</span> <span><b>LCD SPI</b> — GC9A01 1.28″ round 240×240 (onboard, fixed pins)</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="swatch" style="background:#4CAF50"></div> <div class="swatch" style="background:#4CAF50"></div>
<span><b>UART4</b> T4/R4 → ELRS RX (CRSF)</span> <span><b>Power</b> — 5V USB input; 3.3V LDO for logic + sensors</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#F44336"></div>
<span><b>UART5</b> T5/R5 → Debug/spare</span>
</div> </div>
<div class="legend-section"> <div class="legend-section">
<h3>📡 SPI Bus</h3> <h3>🔌 CAN Bus Topology</h3>
<div class="legend-item"> <div class="legend-item">
<span>SPI1: PA5/PA6/PA7 → IMU (CS: <em style="color:#e94560">PA15</em>)</span> <span>Orin → CANable 2.0 → <b>CANH/CANL</b> (500 kbps)</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span>SPI2: PB13-15 → OSD MAX7456</span> <span>BALANCE: SN65HVD230 on CAN bus</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span>SPI3: PB3-5 → Flash W25Q128</span> <span>VESC Left: ID <b>0x44</b> (68) | VESC Right: ID <b>0x38</b> (56)</span>
</div>
<div class="legend-item">
<span>120 Ω termination at each bus end</span>
</div> </div>
</div> </div>
<div class="legend-section"> <div class="legend-section">
<h3>⚡ Other</h3> <h3>📡 Inter-Board Protocol</h3>
<div class="legend-item"> <div class="legend-item">
<span>🔵 LED: PC4 | 📢 Beeper: PC15</span> <span>UART @ 460800 baud, 8N1</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span>🔋 VBAT: PC2 | ⚡ Current: PC1</span> <span>Frame: <code>[0xAA][LEN][TYPE][PAYLOAD][CRC8]</code></span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span>💡 LED Strip: PA1 (WS2812)</span> <span>Types: see <code>esp32/shared/protocol.h</code></span>
</div>
<div class="legend-item">
<span>📍 EXTI (IMU data-ready): PA8</span>
</div> </div>
</div> </div>
</div>
</div>
<div class="orient"> <div class="legend-section">
<h2>🧭 IMU Orientation (CW90° from chip to board)</h2> <h3>⚡ Safety</h3>
<div class="orient-grid"> <div class="legend-item"><span>Motors NEVER spin on power-on — ARM required</span></div>
<div class="orient-item"><span class="dir">Board Forward</span> (tilt for balance) = Chip's +Y axis</div> <div class="legend-item"><span>RC kill switch checked every loop</span></div>
<div class="orient-item"><span class="dir">Board Right</span> = Chip's -X axis</div> <div class="legend-item"><span>CAN watchdog: 500 ms → RC-only mode</span></div>
<div class="orient-item"><span class="dir">Board Pitch Rate</span> = -Gyro X (raw)</div> <div class="legend-item"><span>ESTOP: CAN 0x303 + 0xE5 → all motors off</span></div>
<div class="orient-item"><span class="dir">Board Accel Forward</span> = Accel Y (raw)</div> </div>
</div> </div>
</div> </div>
<p class="note"> <p class="note">
⚠️ Pad positions are <em>approximate</em> — check the physical board silkscreen for exact locations. ⚠️ CAN TX/RX GPIO assignments are <em>TBD</em> — confirm in <code>esp32/balance/src/config.h</code> before wiring.
The CW90 rotation is handled in firmware (mpu6000.c). USB-C at bottom edge for DFU flashing. All inter-board UART GPIO also TBD. LCD and IMU pins are fixed by Waveshare hardware.
</p> </p>
</body> </body>

View File

@ -1,212 +1,174 @@
# SaltyLab / SAUL-TEE Wiring Reference # SAUL-TEE Wiring Reference
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** Mamba F722S / STM32 retired. **Authoritative reference:** [`docs/SAUL-TEE-SYSTEM-REFERENCE.md`](SAUL-TEE-SYSTEM-REFERENCE.md)
> New stack: **ESP32-S3 BALANCE** + **ESP32-S3 IO** + VESCs on 500 kbps CAN.
> **Authoritative reference:** [`docs/SAUL-TEE-SYSTEM-REFERENCE.md`](SAUL-TEE-SYSTEM-REFERENCE.md) This document is a quick-access wiring summary. For pin assignments, CAN frame formats,
> Historical STM32/Mamba wiring below is **obsolete** — retained for reference only. RC channel mapping, and serial commands, see the full reference doc.
--- ---
## ~~System Overview~~ (OBSOLETE — see SAUL-TEE-SYSTEM-REFERENCE.md) ## System Block Diagram
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────┐
│ ORIN NANO SUPER │ │ JETSON ORIN NANO SUPER │
│ (Top Plate — 25W) │ │ (Top plate, 25W) │
│ │ │ │
<<<<<<< HEAD │ USB-A ──── CANable 2.0 USB↔CAN (can0, 500 kbps) │
│ USB-A ──── CANable2 USB-CAN adapter (slcan0, 500 kbps) │ │ USB-A ──── RealSense D435i (USB 3.1) │
│ USB-A ──── ESP32-S3 IO (/dev/esp32-io, 460800 baud) │ │ USB-A ──── RPLIDAR A1M8 (CP2102, 115200) │
======= │ USB-C ──── SIM7600A 4G/LTE modem │
│ USB-C ──── ESP32-S3 CDC (/dev/esp32-bridge, 921600 baud) │ │ CSI-A ─── 2× IMX219 cameras (front + left) │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only) │ CSI-B ─── 2× IMX219 cameras (rear + right) │
│ USB-A1 ─── RealSense D435i (USB 3.1) │ │ 40-pin ── ReSpeaker 2-Mic HAT │
│ USB-A2 ─── RPLIDAR A1M8 (via CP2102 adapter, 115200) │ └──────────────────────┬───────────────────────────────────┘
│ USB-C* ─── SIM7600A 4G/LTE modem (ttyUSB0-2, AT cmds + PPP) │ │ USB-A → CANable 2.0
│ USB ─────── Leap Motion Controller (hand/gesture tracking) │ │ can0, 500 kbps
│ CSI-A ──── ArduCam adapter → 2x IMX219 (front + left) │ ┌────────────────────────────────┴──────────────────────────────────┐
│ CSI-B ──── ArduCam adapter → 2x IMX219 (rear + right) │ │ CAN BUS (CANH / CANL / GND) │
│ M.2 ───── 1TB NVMe SSD │ │ 120 Ω ─┤ ├─ 120 Ω │
│ 40-pin ─── ReSpeaker 2-Mic HAT (I2S + I2C, WM8960 codec) │ └───────────┬──────────────────────────────────────────┬────────────┘
│ Pin 8 ──┐ │ │ │
│ Pin 10 ─┤ UART fallback to ESP32-S3 BALANCE (ttyTHS0, 460800) │ ┌───────────┴────────────┐ ┌─────────────┴──────────┐
│ Pin 6 ──┘ GND │ │ ESP32-S3 BALANCE │ │ VESC left (ID 68) │
│ │ │ Waveshare Touch LCD │ │ VESC right (ID 56) │
└─────────────────────────────────────────────────────────────────────┘ │ 1.28 — CH343 USB │ │ FSESC 6.7 Pro Mini │
│ USB-A (CANable2) │ UART fallback (3 wires) │ │ │ Dual │
│ SocketCAN slcan0 │ 460800 baud, 3.3V │ QMI8658 IMU (I2C) │ └──────┬─────────────────┘
│ 500 kbps │ │ SN65HVD230 (CAN) │ │ Phase wires
▼ ▼ │ │ ┌────────┴─────────────┐
┌─────────────────────────────────────────────────────────────────────┐ │ UART ──────────────┐ │ │ Hub motors (4×) │
<<<<<<< HEAD └────────────────────────┘ │ FL / FR / RL / RR │
│ ESP32-S3 BALANCE │ ↕ 460800 baud binary │ └──────────────────────┘
│ (Waveshare Touch LCD 1.28, Middle Plate) │ inter-board proto │
======= ┌───────────────────────┘
│ ESP32-S3 BALANCE (FC) │ │ ESP32-S3 IO (bare board)
│ (Middle Plate — foam mounted) │ │ JTAG USB
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │ │ UART0 ── TBS Crossfire RX (CRSF @ 420000)
│ CAN bus ──── CANable2 → Orin (primary link, ISO 11898) │ │ UART2 ── ELRS receiver (CRSF failover @ 420000)
│ UART0 ──── Orin UART fallback (460800 baud, 3.3V) │ │ PWM ──── 4× BTS7960 H-bridge motor drivers
│ UART1 ──── VESC Left (CAN ID 56) via UART/CAN bridge │ │ I2C ──── NFC + Barometer + ToF (shared bus)
│ UART2 ──── VESC Right (CAN ID 68) via UART/CAN bridge │ │ RMT ──── WS2812B LED strip
│ I2C ──── QMI8658 IMU (onboard, 6-DOF accel+gyro) │ │ GPIO ─── Horn / Headlight / Fan / Buzzer
│ SPI ──── GC9A01 LCD (onboard, 240x240 round display) │ └──────────────────────────────────────────────
│ GPIO ──── WS2812B LED strip │
│ GPIO ──── Buzzer │
│ ADC ──── Battery voltage divider │
│ │
└─────────────────────────────────────────────────────────────────────┘
│ CAN bus (ISO 11898) │ UART (460800 baud)
│ 500 kbps │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────┐
│ VESC Left (ID 56) │ │ VESC Right (ID 68) │
│ (Bottom Plate) │ │ (Bottom Plate) │
│ │ │ │
│ BLDC hub motor │ │ BLDC hub motor │
│ CAN 500 kbps │ │ CAN 500 kbps │
│ FOC current control │ │ FOC current control │
│ VESC Status 1 (0x900) │ │ VESC Status 1 (0x910) │
│ │ │ │
└────────────────────────┘ └──────────────────────────┘
│ │
LEFT MOTOR RIGHT MOTOR
``` ```
---
## Wire-by-Wire Connections ## Wire-by-Wire Connections
<<<<<<< HEAD ### 1. Orin ↔ CAN Bus (via CANable 2.0)
### 1. Orin <-> ESP32-S3 BALANCE (Primary: CAN Bus via CANable2)
=======
### 1. Orin ↔ FC (Primary: USB Serial (CH343))
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| From | To | Wire | Notes |
|------|----|------|-------|
| Orin USB-A | CANable2 USB | USB cable | SocketCAN slcan0 @ 500 kbps |
| CANable2 CAN-H | ESP32-S3 BALANCE CAN-H | twisted pair | ISO 11898 differential |
| CANable2 CAN-L | ESP32-S3 BALANCE CAN-L | twisted pair | ISO 11898 differential |
<<<<<<< HEAD
- Interface: SocketCAN `slcan0`, 500 kbps
- Device node: `/dev/canable2` (via udev, symlink to ttyUSBx)
- Protocol: CAN frames --- ORIN_CMD_DRIVE (0x300), ORIN_CMD_MODE (0x301), ORIN_CMD_ESTOP (0x302)
- Telemetry: BALANCE_STATUS (0x400), BALANCE_VESC (0x401), BALANCE_IMU (0x402), BALANCE_BATTERY (0x403)
=======
- Device: `/dev/ttyACM0` → symlink `/dev/esp32-bridge`
- Baud: 921600, 8N1
- Protocol: JSON telemetry (FC→Orin), ASCII commands (Orin→FC)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### 2. Orin <-> ESP32-S3 BALANCE (Fallback: Hardware UART)
| Orin Pin | Signal | ESP32-S3 Pin | Notes |
|----------|--------|--------------|-------|
| Pin 8 | TXD0 | GPIO17 (UART0 RX) | Orin TX -> BALANCE RX |
| Pin 10 | RXD0 | GPIO18 (UART0 TX) | Orin RX <- BALANCE TX |
| Pin 6 | GND | GND | Common ground |
- Jetson device: `/dev/ttyTHS0`
- Baud: 460800, 8N1
- Voltage: 3.3V both sides (no level shifter needed)
- Cross-connect: Orin TX -> BALANCE RX, Orin RX <- BALANCE TX
### 3. Orin <-> ESP32-S3 IO (USB Serial)
| From | To | Notes | | From | To | Notes |
|------|----|-------| |------|----|-------|
| Orin USB-A | ESP32-S3 IO USB-C | USB cable, /dev/esp32-io | | Orin USB-A | CANable 2.0 USB | `/dev/canable0``can0` |
| CANable CANH | CAN bus CANH | Twisted pair |
| CANable CANL | CAN bus CANL | Twisted pair |
| CANable GND | CAN GND | Common |
- Device node: `/dev/esp32-io` (udev symlink) Setup: `ip link set can0 up type can bitrate 500000`
- Baud: 460800, 8N1
- Protocol: Binary frames `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
- Use: IO expansion, GPIO control, sensor polling
### 4. ESP32-S3 BALANCE <-> VESC Motors (CAN Bus) ### 2. ESP32-S3 BALANCE ↔ CAN Bus
| BALANCE Pin | Signal | VESC Pin | Notes | | Signal | GPIO | CAN bus |
|-------------|--------|----------|-------| |--------|------|---------|
| GPIO21 | CAN-H | CAN-H | ISO 11898 differential pair | | CAN TX | TBD | → SN65HVD230 D pin |
| GPIO22 | CAN-L | CAN-L | ISO 11898 differential pair | | CAN RX | TBD | ← SN65HVD230 R pin |
| GND | GND | GND | Common ground |
- Baud: 500 kbps CAN > TBD pins — confirm in `esp32/balance/src/config.h`
- VESC Left: CAN ID 56, VESC Right: CAN ID 68
- Commands: COMM_SET_RPM, COMM_SET_CURRENT, COMM_SET_DUTY
- Telemetry: VESC Status 1 at 50 Hz (RPM, current, duty)
### 5. Power Distribution ### 3. ESP32-S3 BALANCE ↔ ESP32-S3 IO (Inter-Board UART)
| Signal | BALANCE GPIO | IO GPIO | Baud |
|--------|-------------|---------|------|
| TX | TBD | TBD (RX) | 460800 |
| RX | TBD (RX) | TBD (TX) | 460800 |
| GND | GND | GND | — |
Frame: `[0xAA][LEN][TYPE][PAYLOAD…][CRC8]` — see `esp32/shared/protocol.h`
### 4. ESP32-S3 IO ↔ TBS Crossfire RX (UART0)
| IO GPIO | Signal | Crossfire pin | Notes |
|---------|--------|---------------|-------|
| GPIO43 | TX | RX | CRSF telemetry to TX module |
| GPIO44 | RX | TX | CRSF RC frames in |
| GND | GND | GND | |
| 5V | — | VCC | Power from 5V bus |
Baud: 420000 (CRSF). Failsafe: disarm after 300 ms without frame.
### 5. ESP32-S3 IO ↔ ELRS Receiver (UART2, failover)
| IO GPIO | Signal | ELRS pin |
|---------|--------|----------|
| TBD | TX | RX |
| TBD | RX | TX |
| GND | GND | GND |
| 5V | — | VCC |
Baud: 420000 (CRSF). Activates automatically if Crossfire link lost >300 ms.
### 6. ESP32-S3 IO ↔ BTS7960 Motor Drivers (4×)
TBD GPIO assignments — see `esp32/io/src/config.h`.
| Signal | Per-driver | Notes |
|--------|-----------|-------|
| RPWM | GPIO TBD | Forward PWM |
| LPWM | GPIO TBD | Reverse PWM |
| R_EN | GPIO TBD | Enable H |
| L_EN | GPIO TBD | Enable H |
| Motor+ / Motor | Hub motor | 36V via B+ / B on BTS7960 |
### 7. ESP32-S3 IO I2C Sensors
| Device | I2C Address | Notes |
|--------|-------------|-------|
| NFC (PN532) | 0x24 | NFC tag read/write |
| Barometer (BMP280/388) | 0x76 | Altitude + temp |
| ToF range (VL53L0X) | 0x29 | Proximity/obstacle |
> SDA / SCL GPIOs TBD — confirm in `esp32/io/src/config.h`
### 8. Power Distribution
``` ```
BATTERY (36V) ──┬── VESC Left (36V direct -> BLDC left motor) 36V BATTERY
├── VESC Right (36V direct -> BLDC right motor)
├── VESC left (36V) ─── Front-left + Rear-left hub motors
├── 5V BEC/regulator ──┬── Orin (USB-C PD or barrel jack) ├── VESC right (36V) ─── Front-right + Rear-right hub motors
│ ├── ESP32-S3 BALANCE (5V via USB-C) ├── BTS7960 boards (B+/B) — 36V motor power
│ ├── ESP32-S3 IO (5V via USB-C)
│ ├── WS2812B LEDs (5V) ├── DC-DC 12V ──── Fan / Headlight / Accessories
│ └── RPLIDAR (5V via USB)
└── DC-DC 5V ─┬── Jetson Orin (USB-C PD)
└── Battery monitor ──── ESP32-S3 BALANCE ADC (voltage divider) ├── ESP32-S3 BALANCE (USB 5V)
├── ESP32-S3 IO (USB 5V)
├── TBS Crossfire RX (5V)
├── ELRS RX (5V)
├── WS2812B strip (5V)
├── RPLIDAR A1M8 (5V via USB)
└── Sensors (3.3V from ESP32-IO LDO)
``` ```
### 6. Sensors on Orin (USB/CSI) ---
| Device | Interface | Orin Port | Device Node | ## Orin USB Peripherals
|--------|-----------|-----------|-------------|
| RealSense D435i | USB 3.1 | USB-A (blue) | `/dev/bus/usb/...` |
| RPLIDAR A1M8 | USB-UART | USB-A | `/dev/rplidar` |
| IMX219 front+left | MIPI CSI-2 | CSI-A (J5) | `/dev/video0,2` |
| IMX219 rear+right | MIPI CSI-2 | CSI-B (J8) | `/dev/video4,6` |
| 1TB NVMe | PCIe Gen3 x4 | M.2 Key M | `/dev/nvme0n1` |
| CANable2 | USB-CAN | USB-A | `/dev/canable2` -> `slcan0` |
| Device | Interface | Node |
|--------|-----------|------|
| CANable 2.0 | USB-A | `can0` (after `ip link set can0 up type can bitrate 500000`) |
| RealSense D435i | USB 3.1 | `/dev/bus/usb/...` |
| RPLIDAR A1M8 | USB-UART | `/dev/rplidar` |
| SIM7600A 4G | USB | `/dev/ttyUSB02` |
| ESP32-S3 BALANCE debug | USB-A (CH343) | `/dev/esp32-balance` |
| ESP32-S3 IO debug | USB-A (JTAG/CDC) | `/dev/esp32-io` |
<<<<<<< HEAD ---
## FC UART Summary (MAMBA F722S — OBSOLETE)
| Interface | Pins | Baud/Rate | Assignment | Notes | ## ReSpeaker 2-Mic HAT (Orin 40-pin)
|-----------|------|-----------|------------|-------|
| UART0 | GPIO17=RX, GPIO18=TX | 460800 | Orin UART fallback | 3.3V, cross-connect |
| UART1 | GPIO19=RX, GPIO20=TX | 115200 | Debug serial | Optional |
| CAN (TWAI) | GPIO21=H, GPIO22=L | 500 kbps | CAN bus (VESCs + Orin) | SN65HVD230 transceiver |
| I2C | GPIO4=SDA, GPIO5=SCL | 400 kHz | QMI8658 IMU (addr 0x6B) | Onboard |
| SPI | GPIO36=MOSI, GPIO37=SCLK, GPIO35=CS | 40 MHz | GC9A01 LCD (onboard) | 240x240 round |
| USB CDC | USB-C | 460800 | Orin USB fallback | /dev/esp32-balance |
## CAN Frame ID Map
| CAN ID | Direction | Name | Contents |
|--------|-----------|------|----------|
| 0x300 | Orin -> BALANCE | ORIN_CMD_DRIVE | left_rpm_f32, right_rpm_f32 (8 bytes LE) |
| 0x301 | Orin -> BALANCE | ORIN_CMD_MODE | mode byte (0=IDLE, 1=DRIVE, 2=ESTOP) |
| 0x302 | Orin -> BALANCE | ORIN_CMD_ESTOP | flags byte (bit0=stop, bit1=clear) |
| 0x400 | BALANCE -> Orin | BALANCE_STATUS | pitch x10:i16, motor_cmd:u16, vbat_mv:u16, state:u8, flags:u8 |
| 0x401 | BALANCE -> Orin | BALANCE_VESC | l_rpm x10:i16, r_rpm x10:i16, l_cur x10:i16, r_cur x10:i16 |
| 0x402 | BALANCE -> Orin | BALANCE_IMU | pitch x100:i16, roll x100:i16, yaw x100:i16, ax x100:i16, ay x100:i16, az x100:i16 |
| 0x403 | BALANCE -> Orin | BALANCE_BATTERY | vbat_mv:u16, current_ma:i16, soc_pct:u8 |
| 0x900+ID | VESC Left -> | VESC_STATUS_1 | erpm:i32, current x10:i16, duty x1000:i16 |
| 0x910+ID | VESC Right -> | VESC_STATUS_1 | erpm:i32, current x10:i16, duty x1000:i16 |
VESC Left CAN ID = 56 (0x38), VESC Right CAN ID = 68 (0x44).
=======
## FC UART Summary (ESP32-S3 BALANCE)
| UART | Pins | Baud | Assignment | Notes |
|------|------|------|------------|-------|
| USART1 | PB6=TX, PB7=RX | — | SmartAudio/VTX | Unused in SaltyLab |
| USART2 | PA2=TX, PA3=RX | 26400 | Hoverboard ESC | Binary motor commands |
| USART3 | PB10=TX, PB11=RX | — | Available | Was SBUS default |
| UART4 | PA0=TX, PA1=RX | 420000 | ELRS RX (CRSF) | RC control |
| UART5 | PC12=TX, PD2=RX | 115200 | Debug serial | Optional |
| USART6 | PC6=TX, PC7=RX | 921600 | Jetson UART | Fallback link |
| USB Serial (CH343) | USB-C | 921600 | Jetson primary | `/dev/esp32-bridge` |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### 7. ReSpeaker 2-Mic HAT (on Orin 40-pin header)
| Orin Pin | Signal | Function | | Orin Pin | Signal | Function |
|----------|--------|----------| |----------|--------|----------|
@ -214,117 +176,20 @@ VESC Left CAN ID = 56 (0x38), VESC Right CAN ID = 68 (0x44).
| Pin 35 (GPIO 19) | I2S LRCLK | Audio left/right clock | | Pin 35 (GPIO 19) | I2S LRCLK | Audio left/right clock |
| Pin 38 (GPIO 20) | I2S DIN | Audio data in (from mics) | | Pin 38 (GPIO 20) | I2S DIN | Audio data in (from mics) |
| Pin 40 (GPIO 21) | I2S DOUT | Audio data out (to speaker) | | Pin 40 (GPIO 21) | I2S DOUT | Audio data out (to speaker) |
| Pin 3 (GPIO 2) | I2C SDA | WM8960 codec control (i2c-7) | | Pin 3 (GPIO 2) | I2C SDA | WM8960 codec (i2c-7) |
| Pin 5 (GPIO 3) | I2C SCL | WM8960 codec control (i2c-7) | | Pin 5 (GPIO 3) | I2C SCL | WM8960 codec (i2c-7) |
| Pin 32 (GPIO 12) | GPIO | Button input |
| Pin 11 (GPIO 17) | GPIO | RGB LED (APA102 data) |
| Pin 2, 4 | 5V | Power | | Pin 2, 4 | 5V | Power |
| Pin 6, 9 | GND | Ground | | Pin 6, 9 | GND | Ground |
- Codec: Wolfson WM8960 (I2C addr 0x1A) Codec: Wolfson WM8960 (0x1A). Use: voice commands, wake word, audio feedback.
- Mics: 2x MEMS (left + right) --- basic stereo / sound localization
- Speaker: 3W class-D amp output (JST connector)
- Headset: 3.5mm TRRS jack
- Requires: WM8960 device tree overlay for Jetson (community port)
- Use: Voice commands (faster-whisper), wake word (openWakeWord), audio feedback, status announcements
### 8. SIM7600A 4G/LTE HAT (via USB)
| Connection | Detail |
|-----------|--------|
| Interface | USB (micro-B on HAT -> USB-A/C on Orin) |
| Device nodes | `/dev/ttyUSB0` (AT), `/dev/ttyUSB1` (PPP/data), `/dev/ttyUSB2` (GPS NMEA) |
| Power | 5V from USB or separate 5V supply (peak 2A during TX) |
| SIM | Nano-SIM slot on HAT |
| Antenna | 4G LTE + GPS/GNSS (external SMA antennas --- mount high on chassis) |
- Data: PPP or QMI for internet connectivity
- GPS/GNSS: Built-in receiver, NMEA sentences on ttyUSB2 --- outdoor positioning
- AT commands: `AT+CGPS=1` (enable GPS), `AT+CGPSINFO` (get fix)
- Connected via USB (not 40-pin) --- avoids UART conflict with BALANCE fallback, flexible antenna placement
- Use: Remote telemetry, 4G connectivity outdoors, GPS positioning, remote SSH/control
### 9. Leap Motion Controller (USB)
| Connection | Detail |
|-----------|--------|
| Interface | USB 3.0 (micro-B on controller -> USB-A on Orin) |
| Power | ~0.5W |
| Range | ~80cm, 150 deg FOV |
| SDK | Ultraleap Gemini V5+ (Linux ARM64 support) |
| ROS2 | `leap_motion_ros2` wrapper available |
- 2x IR cameras + 3x IR LEDs --- tracks all 10 fingers in 3D, sub-mm precision
- Mount: Forward-facing on sensor tower or upward on Orin plate
- Use: Gesture control (palm=stop, point=go, fist=arm), hand-following mode, demos
- Combined with ReSpeaker: Voice + gesture control with zero hardware in hand
### 10. Power Budget (USB)
| Device | Interface | Power Draw |
|--------|-----------|------------|
<<<<<<< HEAD
| CANable2 USB-CAN | USB-A | ~0.5W |
| ESP32-S3 BALANCE | USB-C | ~0.8W (WiFi off) |
| ESP32-S3 IO | USB-C | ~0.5W |
=======
| ESP32-S3 FC (CDC) | USB-C | ~0.5W (data only, FC on 5V bus) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| RealSense D435i | USB-A | ~1.5W (3.5W peak) |
| RPLIDAR A1M8 | USB-A | ~2.6W (motor on) |
| SIM7600A | USB | ~1W idle, 3W TX peak |
| Leap Motion | USB-A | ~0.5W |
| ReSpeaker HAT | 40-pin | ~0.5W |
| **Total USB** | | **~7.9W typical, ~11W peak** |
Orin Nano Super delivers up to 25W --- USB peripherals are well within budget.
--- ---
## Data Flow ## SIM7600A 4G/LTE HAT (Orin USB)
``` | Connection | Detail |
┌──────────────┐ |-----------|--------|
│ RC TX │ (in your hand) | Interface | USB (micro-B on HAT → USB-A on Orin) |
│ (2.4GHz) │ | Device nodes | `/dev/ttyUSB0` (AT), `/dev/ttyUSB1` (PPP/data), `/dev/ttyUSB2` (GPS NMEA) |
└──────┬───────┘ | Power | 5V from USB (peak 2A during TX) |
│ radio | SIM | Nano-SIM slot |
┌──────▼───────┐
│ RC RX │ CRSF 420kbaud (future)
└──────┬───────┘
│ UART
┌────────────▼────────────┐
<<<<<<< HEAD
│ ESP32-S3 BALANCE │
│ (Waveshare LCD 1.28) │
=======
│ ESP32-S3 BALANCE │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │
│ QMI8658 -> Balance PID │
│ RC -> Mode Manager │
│ Safety Monitor │
│ │
└──┬──────────┬───────────┘
<<<<<<< HEAD
CAN 500kbps─┘ └───── CAN bus / UART fallback
=======
USART2 ─────┘ └───── USB Serial (CH343) / USART6
26400 baud 921600 baud
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │
┌────┴────────────┐ ▼
│ CAN bus (500k) │ ┌───────────────────┐
├─ VESC Left 56 │ │ Orin Nano Super │
└─ VESC Right 68 │ │ │
│ │ │ SLAM / Nav2 / AI │
▼ ▼ │ Person following │
LEFT RIGHT │ Voice commands │
MOTOR MOTOR │ 4G telemetry │
└──┬──────────┬───────┘
│ │
┌──────────▼─┐ ┌────▼──────────┐
│ ReSpeaker │ │ SIM7600A │
│ 2-Mic HAT │ │ 4G/LTE + GPS │
└────────────┘ └───────────────┘
```

View File

@ -6,19 +6,15 @@ Self-balancing robot: Jetson Orin Nano Super dev environment for ROS2 Humble + S
| Component | Version / Part | | Component | Version / Part |
|-----------|---------------| |-----------|---------------|
| Platform | Jetson Orin Nano Super 4GB | | Platform | Jetson Orin Nano Super 8GB |
| JetPack | 4.6 (L4T R32.6.1, CUDA 10.2) | | JetPack | 6.x (L4T R36.x, CUDA 12.x) |
| ROS2 | Humble Hawksbill | | ROS2 | Humble Hawksbill |
| DDS | CycloneDDS | | DDS | CycloneDDS |
| SLAM | slam_toolbox | | SLAM | slam_toolbox |
| Nav | Nav2 | | Nav | Nav2 |
| Depth camera | Intel RealSense D435i | | Depth camera | Intel RealSense D435i |
| LiDAR | RPLIDAR A1M8 | | LiDAR | RPLIDAR A1M8 |
<<<<<<< HEAD | MCU bridge | ESP32-S3 BALANCE (CAN bus @ 500 kbps via CANable 2.0) |
| MCU bridge | ESP32 (USB CDC @ 921600) |
=======
| MCU bridge | ESP32-S3 (USB Serial (CH343) @ 921600) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Quick Start ## Quick Start
@ -46,15 +42,11 @@ bash scripts/build-and-run.sh shell
``` ```
jetson/ jetson/
├── Dockerfile # L4T base + ROS2 Humble + SLAM packages ├── Dockerfile # L4T base + ROS2 Humble + SLAM packages
<<<<<<< HEAD ├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, CAN bridge)
├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, ESP32 BALANCE)
=======
├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, ESP32-S3)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
├── README.md # This file ├── README.md # This file
├── docs/ ├── docs/
│ ├── pinout.md # GPIO/I2C/UART pinout reference │ ├── pinout.md # GPIO/I2C/UART pinout reference
│ └── power-budget.md # Power budget analysis (10W envelope) │ └── power-budget.md # Power budget analysis (25W envelope)
└── scripts/ └── scripts/
├── entrypoint.sh # Docker container entrypoint ├── entrypoint.sh # Docker container entrypoint
├── setup-jetson.sh # Host setup (udev, Docker, nvpmodel) ├── setup-jetson.sh # Host setup (udev, Docker, nvpmodel)
@ -66,8 +58,8 @@ jetson/
| Scenario | Total | | Scenario | Total |
|---------|-------| |---------|-------|
| Idle | 2.9W | | Idle | 2.9W |
| Nominal (SLAM active) | ~10.2W | | Nominal (SLAM active) | ~19.9W |
| Peak | 15.4W | | Peak | ~28.2W |
Target: 10W (MAXN nvpmodel). Use RPLIDAR standby + 640p D435i for compliance. Target: 25W (MAXN nvpmodel). 5W headroom at nominal load.
See [`docs/power-budget.md`](docs/power-budget.md) for full analysis. See [`docs/power-budget.md`](docs/power-budget.md) for full analysis.

View File

@ -10,7 +10,7 @@ Recovery behaviors are triggered when Nav2 encounters navigation failures (path
### Backup Recovery (Issue #479) ### Backup Recovery (Issue #479)
- **Distance**: 0.3 meters reverse - **Distance**: 0.3 meters reverse
- **Speed**: 0.1 m/s (very conservative for FC + Hoverboard ESC) - **Speed**: 0.1 m/s (very conservative for ESP32-S3 BALANCE + VESC)
- **Max velocity**: 0.15 m/s (absolute limit) - **Max velocity**: 0.15 m/s (absolute limit)
- **Time limit**: 5 seconds maximum - **Time limit**: 5 seconds maximum
@ -34,20 +34,16 @@ Recovery behaviors are triggered when Nav2 encounters navigation failures (path
The emergency stop system (Issue #459, `saltybot_emergency` package) runs independently of Nav2 and takes absolute priority. The emergency stop system (Issue #459, `saltybot_emergency` package) runs independently of Nav2 and takes absolute priority.
<<<<<<< HEAD Recovery behaviors cannot interfere with E-stop because the emergency system operates at the motor driver level on the ESP32-S3 BALANCE firmware.
Recovery behaviors cannot interfere with E-stop because the emergency system operates at the motor driver level on the ESP32 BALANCE firmware.
=======
Recovery behaviors cannot interfere with E-stop because the emergency system operates at the motor driver level on the ESP32-S3 firmware.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Behavior Tree Sequence ## Behavior Tree Sequence
Recovery runs in a round-robin fashion with up to 6 retry cycles. Recovery runs in a round-robin fashion with up to 6 retry cycles.
## Constraints for FC + Hoverboard ESC ## Constraints for ESP32-S3 BALANCE + VESC
This configuration is specifically tuned for: This configuration is specifically tuned for:
- **Drivetrain**: Flux Capacitor (FC) balancing controller + Hoverboard brushless ESC - **Drivetrain**: ESP32-S3 BALANCE + VESC (CAN bus)
- **Max linear velocity**: 1.0 m/s - **Max linear velocity**: 1.0 m/s
- **Max angular velocity**: 1.5 rad/s - **Max angular velocity**: 1.5 rad/s
- **Recovery velocity constraints**: 50% of normal for stability - **Recovery velocity constraints**: 50% of normal for stability

View File

@ -1,9 +1,5 @@
# Jetson Orin Nano Super — GPIO / I2C / UART / CSI Pinout Reference # Jetson Orin Nano Super — GPIO / I2C / UART / CSI Pinout Reference
<<<<<<< HEAD ## Self-Balancing Robot: ESP32-S3 BALANCE (CAN bus) + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
## Self-Balancing Robot: ESP32 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
=======
## Self-Balancing Robot: ESP32-S3 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
Last updated: 2026-02-28 Last updated: 2026-02-28
JetPack version: 6.x (L4T R36.x / Ubuntu 22.04) JetPack version: 6.x (L4T R36.x / Ubuntu 22.04)
@ -47,75 +43,36 @@ i2cdetect -l
--- ---
<<<<<<< HEAD ## 1. ESP32-S3 BALANCE Bridge (CAN bus — Primary)
## 1. ESP32 Bridge (USB CDC — Primary)
The ESP32 BALANCE acts as a real-time motor + IMU controller. Communication is via **USB CDC serial**. The ESP32-S3 BALANCE acts as a real-time motor + IMU controller. Communication is via **CAN bus** through a CANable 2.0 USB-CAN adapter.
=======
## 1. ESP32-S3 Bridge (USB Serial (CH343) — Primary)
The ESP32-S3 acts as a real-time motor + IMU controller. Communication is via **USB Serial (CH343) serial**. ### CAN Bus Connection
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### USB Serial (CH343) Connection
| Connection | Detail | | Connection | Detail |
|-----------|--------| |-----------|--------|
<<<<<<< HEAD | Interface | CANable 2.0 USB-CAN adapter → USB-A on Jetson |
| Interface | USB on ESP32 BALANCE board → USB-A on Jetson | | Device node | `/dev/can0` (via CANable 2.0, SocketCAN) |
| Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) | | Bitrate | 500 kbps |
| Baud rate | 921600 (configured in ESP32 BALANCE firmware) | | Protocol | Binary CAN frames (see balance_protocol.py) |
======= | Power | ESP32-S3 BALANCE powered from robot 5V DC-DC |
| Interface | USB Micro-B on ESP32-S3 dev board → USB-A on Jetson |
| Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) |
| Baud rate | 921600 (configured in ESP32-S3 firmware) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Protocol | JSON telemetry RX + ASCII command TX (see bridge docs) |
| Power | Powered via robot 5V bus (data-only via USB) |
### Hardware UART (Fallback — 40-pin header)
<<<<<<< HEAD
| Jetson Pin | Signal | ESP32 Pin | Notes |
=======
| Jetson Pin | Signal | ESP32-S3 Pin | Notes |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|-----------|--------|-----------|-------|
| Pin 8 (TXD0) | TX → | PA10 (UART1 RX) | Cross-connect TX→RX |
| Pin 10 (RXD0) | RX ← | PA9 (UART1 TX) | Cross-connect RX→TX |
| Pin 6 (GND) | GND | GND | Common ground **required** |
**Jetson device node:** `/dev/ttyTHS0`
**Baud rate:** 921600, 8N1
<<<<<<< HEAD
**Voltage level:** 3.3V — both Jetson Orin and ESP32 are 3.3V GPIO
=======
**Voltage level:** 3.3V — both Jetson Orin and ESP32-S3 are 3.3V GPIO
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### Bring Up CAN Interface
```bash ```bash
# Verify UART # Bring up can0 at 500 kbps
ls /dev/ttyTHS0 sudo ip link set can0 up type can bitrate 500000
sudo usermod -aG dialout $USER ip link show can0
# Quick test
picocom -b 921600 /dev/ttyTHS0 # Quick test — dump CAN frames
candump can0
``` ```
<<<<<<< HEAD **ROS2 topics (CAN bridge node):**
**ROS2 topics (ESP32 bridge node):**
| ROS2 Topic | Direction | Content |
|-----------|-----------|---------
| `/saltybot/imu` | ESP32 BALANCE→Jetson | IMU data (accel, gyro) at 50Hz |
| `/saltybot/balance_state` | ESP32 BALANCE→Jetson | Motor cmd, pitch, state |
| `/cmd_vel` | Jetson→ESP32 BALANCE | Velocity commands → `C<spd>,<str>\n` |
| `/saltybot/estop` | Jetson→ESP32 BALANCE | Emergency stop |
=======
**ROS2 topics (ESP32-S3 bridge node):**
| ROS2 Topic | Direction | Content | | ROS2 Topic | Direction | Content |
|-----------|-----------|--------- |-----------|-----------|---------
| `/saltybot/imu` | ESP32-S3→Jetson | IMU data (accel, gyro) at 50Hz | | `/saltybot/imu` | ESP32-S3→Jetson | IMU data (accel, gyro) at 50Hz |
| `/saltybot/balance_state` | ESP32-S3→Jetson | Motor cmd, pitch, state | | `/saltybot/balance_state` | ESP32-S3→Jetson | Motor cmd, pitch, state |
| `/cmd_vel` | Jetson→ESP32-S3 | Velocity commands → `C<spd>,<str>\n` | | `/cmd_vel` | Jetson→CAN 0x300 | Velocity commands via CAN |
| `/saltybot/estop` | Jetson→ESP32-S3 | Emergency stop | | `/saltybot/estop` | Jetson→CAN 0x302 | Emergency stop |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -300,11 +257,7 @@ sudo mkdir -p /mnt/nvme
|------|------|----------| |------|------|----------|
| USB-A (top, blue) | USB 3.1 Gen 1 | RealSense D435i | | USB-A (top, blue) | USB 3.1 Gen 1 | RealSense D435i |
| USB-A (bottom) | USB 2.0 | RPLIDAR (via USB-UART adapter) | | USB-A (bottom) | USB 2.0 | RPLIDAR (via USB-UART adapter) |
<<<<<<< HEAD | USB-C | USB 3.1 Gen 1 (+ DP) | CANable 2.0 or host flash |
| USB-C | USB 3.1 Gen 1 (+ DP) | ESP32 CDC or host flash |
=======
| USB-C | USB 3.1 Gen 1 (+ DP) | ESP32-S3 CDC or host flash |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Micro-USB | Debug/flash | JetPack flash only | | Micro-USB | Debug/flash | JetPack flash only |
--- ---
@ -315,17 +268,10 @@ sudo mkdir -p /mnt/nvme
|-------------|----------|---------|----------| |-------------|----------|---------|----------|
| 3 | SDA1 | 3.3V | I2C data (i2c-7) | | 3 | SDA1 | 3.3V | I2C data (i2c-7) |
| 5 | SCL1 | 3.3V | I2C clock (i2c-7) | | 5 | SCL1 | 3.3V | I2C clock (i2c-7) |
<<<<<<< HEAD | 8 | TXD0 | 3.3V | UART TX → ESP32-S3 IO (inter-board, fallback) |
| 8 | TXD0 | 3.3V | UART TX → ESP32 BALANCE (fallback) | | 10 | RXD0 | 3.3V | UART RX ← ESP32-S3 IO (inter-board, fallback) |
| 10 | RXD0 | 3.3V | UART RX ← ESP32 BALANCE (fallback) |
| USB-A ×2 | — | 5V | D435i, RPLIDAR | | USB-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | ESP32 CDC | | USB-C | — | 5V | CANable 2.0 (CAN bus) |
=======
| 8 | TXD0 | 3.3V | UART TX → ESP32-S3 (fallback) |
| 10 | RXD0 | 3.3V | UART RX ← ESP32-S3 (fallback) |
| USB-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | ESP32-S3 CDC |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| CSI-A (J5) | MIPI CSI-2 | — | Cameras front + left | | CSI-A (J5) | MIPI CSI-2 | — | Cameras front + left |
| CSI-B (J8) | MIPI CSI-2 | — | Cameras rear + right | | CSI-B (J8) | MIPI CSI-2 | — | Cameras rear + right |
| M.2 Key M | PCIe Gen3 ×4 | — | NVMe SSD | | M.2 Key M | PCIe Gen3 ×4 | — | NVMe SSD |
@ -343,13 +289,10 @@ Apply stable device names:
KERNEL=="ttyUSB*", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \ KERNEL=="ttyUSB*", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \
SYMLINK+="rplidar", MODE="0666" SYMLINK+="rplidar", MODE="0666"
<<<<<<< HEAD # CANable 2.0 USB-CAN adapter
# ESP32 USB CDC (STMicroelectronics) # (bring up with: sudo ip link set can0 up type can bitrate 500000)
=======
# ESP32-S3 USB Serial (CH343) (STMicroelectronics)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
KERNEL=="ttyACM*", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", \ KERNEL=="ttyACM*", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", \
SYMLINK+="esp32-bridge", MODE="0666" SYMLINK+="balance", MODE="0666"
# Intel RealSense D435i # Intel RealSense D435i
SUBSYSTEM=="usb", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b3a", \ SUBSYSTEM=="usb", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b3a", \

View File

@ -56,11 +56,7 @@ sudo jtop
|-----------|----------|------------|----------|-----------|-------| |-----------|----------|------------|----------|-----------|-------|
| RealSense D435i | 0.3 | 1.5 | 3.5 | USB 3.1 | Peak during boot/init | | RealSense D435i | 0.3 | 1.5 | 3.5 | USB 3.1 | Peak during boot/init |
| RPLIDAR A1M8 | 0.4 | 2.6 | 3.0 | USB (UART adapter) | Motor spinning | | RPLIDAR A1M8 | 0.4 | 2.6 | 3.0 | USB (UART adapter) | Motor spinning |
<<<<<<< HEAD | ESP32-S3 BALANCE | 0.5 | 0.5 | 0.5 | CAN bus (SN65HVD230) | Powered from 5V DC-DC |
| ESP32 bridge | 0.0 | 0.0 | 0.0 | USB CDC | Self-powered from robot 5V |
=======
| ESP32-S3 bridge | 0.0 | 0.0 | 0.0 | USB Serial (CH343) | Self-powered from robot 5V |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 4× IMX219 cameras | 0.2 | 2.0 | 2.4 | MIPI CSI-2 | ~0.5W per camera active | | 4× IMX219 cameras | 0.2 | 2.0 | 2.4 | MIPI CSI-2 | ~0.5W per camera active |
| **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | | | **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | |
@ -76,7 +72,7 @@ sudo jtop
## Budget Analysis vs Previous Platform ## Budget Analysis vs Previous Platform
| Metric | Jetson Orin Nano Super | Jetson Orin Nano Super | | Metric | Jetson Nano | Jetson Orin Nano Super |
|--------|------------|------------------------| |--------|------------|------------------------|
| TDP | 10W | 25W | | TDP | 10W | 25W |
| CPU | 4× Cortex-A57 @ 1.43GHz | 6× A78AE @ 1.5GHz | | CPU | 4× Cortex-A57 @ 1.43GHz | 6× A78AE @ 1.5GHz |
@ -155,13 +151,9 @@ LiPo 4S (16.8V max)
├─► DC-DC Buck → 5V 6A ──► Jetson Orin barrel jack (30W) ├─► DC-DC Buck → 5V 6A ──► Jetson Orin barrel jack (30W)
│ (e.g., XL4016E1) │ (e.g., XL4016E1)
<<<<<<< HEAD ├─► DC-DC Buck → 5V 3A ──► ESP32-S3 BALANCE + ESP32-S3 IO + logic 5V rail
├─► DC-DC Buck → 5V 3A ──► ESP32 + logic 5V rail
=======
├─► DC-DC Buck → 5V 3A ──► ESP32-S3 + logic 5V rail
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
└─► Hoverboard ESC ──► Hub motors (48V loop) └─► VESC left (ID 68) + VESC right (ID 56) ──► Hub motors
``` ```
Using a 4S LiPo (vs 3S previously) gives better efficiency for the 5V buck converter Using a 4S LiPo (vs 3S previously) gives better efficiency for the 5V buck converter

View File

@ -2,11 +2,7 @@
uart_bridge.launch.py FCOrin UART bridge (Issue #362) uart_bridge.launch.py FCOrin UART bridge (Issue #362)
Launches serial_bridge_node configured for Jetson Orin UART port. Launches serial_bridge_node configured for Jetson Orin UART port.
<<<<<<< HEAD Bridges ESP32-S3 BALANCE telemetry from inter-board UART into ROS2.
Bridges Flight Controller (ESP32) telemetry from /dev/ttyTHS1 into ROS2.
=======
Bridges Flight Controller (ESP32-S3) telemetry from /dev/ttyTHS1 into ROS2.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Published topics (same as USB CDC bridge): Published topics (same as USB CDC bridge):
/saltybot/imu sensor_msgs/Imu pitch/roll/yaw as angular velocity /saltybot/imu sensor_msgs/Imu pitch/roll/yaw as angular velocity
@ -24,11 +20,7 @@ Usage:
Prerequisites: Prerequisites:
- Flight Controller connected to /dev/ttyTHS1 @ 921600 baud - Flight Controller connected to /dev/ttyTHS1 @ 921600 baud
<<<<<<< HEAD - STM32 firmware transmitting JSON telemetry frames (50 Hz)
- ESP32 BALANCE firmware transmitting JSON telemetry frames (50 Hz)
=======
- ESP32-S3 firmware transmitting JSON telemetry frames (50 Hz)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
- ROS2 environment sourced (source install/setup.bash) - ROS2 environment sourced (source install/setup.bash)
Note: Note:

View File

@ -1,9 +1,5 @@
""" """
<<<<<<< HEAD cmd_vel_bridge_node Nav2 /cmd_vel STM32 drive command bridge.
cmd_vel_bridge_node Nav2 /cmd_vel ESP32 BALANCE drive command bridge.
=======
cmd_vel_bridge_node Nav2 /cmd_vel ESP32-S3 drive command bridge.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Extends the basic saltybot_cmd_node with four additions required for safe Extends the basic saltybot_cmd_node with four additions required for safe
autonomous operation on a self-balancing robot: autonomous operation on a self-balancing robot:
@ -16,11 +12,7 @@ autonomous operation on a self-balancing robot:
3. Deadman switch if /cmd_vel is silent for cmd_vel_timeout seconds, 3. Deadman switch if /cmd_vel is silent for cmd_vel_timeout seconds,
zero targets immediately (Nav2 node crash / planner zero targets immediately (Nav2 node crash / planner
stall robot coasts to stop rather than running away). stall robot coasts to stop rather than running away).
<<<<<<< HEAD 4. Mode gate only issue non-zero drive commands when STM32 reports
4. Mode gate only issue non-zero drive commands when ESP32 BALANCE reports
=======
4. Mode gate only issue non-zero drive commands when ESP32-S3 reports
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
md=2 (AUTONOMOUS). In any other mode (RC_MANUAL, md=2 (AUTONOMOUS). In any other mode (RC_MANUAL,
RC_ASSISTED) Jetson cannot override the RC pilot. RC_ASSISTED) Jetson cannot override the RC pilot.
On mode re-entry current ramp state resets to 0 so On mode re-entry current ramp state resets to 0 so
@ -28,15 +20,9 @@ autonomous operation on a self-balancing robot:
Serial protocol (C<speed>,<steer>\\n / H\\n same as saltybot_cmd_node): Serial protocol (C<speed>,<steer>\\n / H\\n same as saltybot_cmd_node):
C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers. C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers.
<<<<<<< HEAD H\\n heartbeat. STM32 reverts steer to 0 after 500ms silence.
H\\n heartbeat. ESP32 BALANCE reverts steer to 0 after 500ms silence.
Telemetry (50 Hz from ESP32 BALANCE): Telemetry (50 Hz from STM32):
=======
H\\n heartbeat. ESP32-S3 reverts steer to 0 after 500ms silence.
Telemetry (50 Hz from ESP32-S3):
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Same RX/publish pipeline as saltybot_cmd_node. Same RX/publish pipeline as saltybot_cmd_node.
The "md" field (0=MANUAL,1=ASSISTED,2=AUTO) is parsed for the mode gate. The "md" field (0=MANUAL,1=ASSISTED,2=AUTO) is parsed for the mode gate.
@ -148,7 +134,7 @@ class CmdVelBridgeNode(Node):
self._current_speed = 0 # ramped output actually sent self._current_speed = 0 # ramped output actually sent
self._current_steer = 0 self._current_steer = 0
self._last_cmd_vel = 0.0 # wall clock (seconds) of last /cmd_vel msg self._last_cmd_vel = 0.0 # wall clock (seconds) of last /cmd_vel msg
self._esp32_mode = 0 # parsed "md" field: 0=MANUAL,1=ASSISTED,2=AUTO self._stm32_mode = 0 # parsed "md" field: 0=MANUAL,1=ASSISTED,2=AUTO
self._last_state = -1 self._last_state = -1
self._frame_count = 0 self._frame_count = 0
self._error_count = 0 self._error_count = 0
@ -164,11 +150,7 @@ class CmdVelBridgeNode(Node):
self._open_serial() self._open_serial()
# ── Timers ──────────────────────────────────────────────────────────── # ── Timers ────────────────────────────────────────────────────────────
<<<<<<< HEAD # Telemetry read at 100 Hz (STM32 sends at 50 Hz)
# Telemetry read at 100 Hz (ESP32 BALANCE sends at 50 Hz)
=======
# Telemetry read at 100 Hz (ESP32-S3 sends at 50 Hz)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self._read_timer = self.create_timer(0.01, self._read_cb) self._read_timer = self.create_timer(0.01, self._read_cb)
# Control loop at 50 Hz: ramp + deadman + mode gate + send # Control loop at 50 Hz: ramp + deadman + mode gate + send
self._control_timer = self.create_timer(1.0 / _CONTROL_HZ, self._control_cb) self._control_timer = self.create_timer(1.0 / _CONTROL_HZ, self._control_cb)
@ -243,7 +225,7 @@ class CmdVelBridgeNode(Node):
# Mode gate: in non-AUTONOMOUS mode, zero and reset ramp state so # Mode gate: in non-AUTONOMOUS mode, zero and reset ramp state so
# re-entry always accelerates smoothly from 0. # re-entry always accelerates smoothly from 0.
if self._esp32_mode != MODE_AUTONOMOUS: if self._stm32_mode != MODE_AUTONOMOUS:
self._current_speed = 0 self._current_speed = 0
self._current_steer = 0 self._current_steer = 0
speed, steer = 0, 0 speed, steer = 0, 0
@ -256,11 +238,7 @@ class CmdVelBridgeNode(Node):
speed = self._current_speed speed = self._current_speed
steer = self._current_steer steer = self._current_steer
<<<<<<< HEAD # Send to STM32
# Send to ESP32 BALANCE
=======
# Send to ESP32-S3
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
frame = f"C{speed},{steer}\n".encode("ascii") frame = f"C{speed},{steer}\n".encode("ascii")
if not self._write(frame): if not self._write(frame):
self.get_logger().warn( self.get_logger().warn(
@ -278,11 +256,7 @@ class CmdVelBridgeNode(Node):
# ── Heartbeat TX ────────────────────────────────────────────────────────── # ── Heartbeat TX ──────────────────────────────────────────────────────────
def _heartbeat_cb(self): def _heartbeat_cb(self):
<<<<<<< HEAD """H\\n keeps STM32 jetson_cmd heartbeat alive regardless of mode."""
"""H\\n keeps ESP32 BALANCE jetson_cmd heartbeat alive regardless of mode."""
=======
"""H\\n keeps ESP32-S3 jetson_cmd heartbeat alive regardless of mode."""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self._write(b"H\n") self._write(b"H\n")
# ── Telemetry RX ────────────────────────────────────────────────────────── # ── Telemetry RX ──────────────────────────────────────────────────────────
@ -345,7 +319,7 @@ class CmdVelBridgeNode(Node):
state = int(data["s"]) state = int(data["s"])
mode = int(data.get("md", 0)) # 0=MANUAL if not present mode = int(data.get("md", 0)) # 0=MANUAL if not present
self._esp32_mode = mode self._stm32_mode = mode
self._frame_count += 1 self._frame_count += 1
self._publish_imu(pitch_deg, roll_deg, yaw_deg, now) self._publish_imu(pitch_deg, roll_deg, yaw_deg, now)
@ -404,11 +378,7 @@ class CmdVelBridgeNode(Node):
diag.header.stamp = stamp diag.header.stamp = stamp
status = DiagnosticStatus() status = DiagnosticStatus()
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
=======
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
status.message = f"{state_label} [{mode_label}]" status.message = f"{state_label} [{mode_label}]"
status.level = ( status.level = (
DiagnosticStatus.OK if state == 1 else DiagnosticStatus.OK if state == 1 else
@ -436,19 +406,11 @@ class CmdVelBridgeNode(Node):
status = DiagnosticStatus() status = DiagnosticStatus()
status.level = DiagnosticStatus.ERROR status.level = DiagnosticStatus.ERROR
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
status.message = f"IMU fault errno={errno}" status.message = f"IMU fault errno={errno}"
diag.status.append(status) diag.status.append(status)
self._diag_pub.publish(diag) self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE IMU fault: errno={errno}") self.get_logger().error(f"STM32 IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32-S3 IMU fault: errno={errno}")
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
# ── Lifecycle ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────

View File

@ -1,66 +1,45 @@
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py """esp32_cmd_node.py — Full bidirectional binary-framed ESP32-S3 BALANCE↔Jetson bridge.
"""stm32_cmd_node.py — Orin ↔ ESP32-S3 IO auxiliary bridge node.
=======
"""esp32_cmd_node.py — Full bidirectional binary-framed ESP32-S3↔Jetson bridge.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
Connects to the ESP32-S3 IO board via USB-CDC (/dev/esp32-io) using the Issue #119: replaces the ASCII-protocol saltybot_cmd_node with a robust binary
inter-board binary protocol (docs/SAUL-TEE-SYSTEM-REFERENCE.md §5). framing protocol (STX/TYPE/LEN/PAYLOAD/CRC16/ETX) at 460800 baud (inter-board UART).
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py TX commands (Jetson ESP32-S3 BALANCE):
This node is NOT the primary drive path (that is CAN via can_bridge_node).
It handles auxiliary I/O: RC monitoring, sensor data, LED/output control.
=======
TX commands (Jetson ESP32-S3):
SPEED_STEER 50 Hz from /cmd_vel subscription SPEED_STEER 50 Hz from /cmd_vel subscription
HEARTBEAT 200 ms timer (ESP32-S3 watchdog fires at 500 ms) HEARTBEAT 200 ms timer (ESP32-S3 BALANCE watchdog fires at 500 ms)
ARM via /saltybot/arm service ARM via /saltybot/arm service
SET_MODE via /saltybot/set_mode service SET_MODE via /saltybot/set_mode service
PID_UPDATE via /saltybot/pid_update topic PID_UPDATE via /saltybot/pid_update topic
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
Frame format: [0xAA][LEN][TYPE][PAYLOAD][CRC8] @ 460800 baud Watchdog: if /cmd_vel is silent for 500 ms, send SPEED_STEER(0,0) and log warning.
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py RX telemetry (ESP32-S3 BALANCE Jetson):
RX from ESP32 IO:
RC_CHANNELS (0x01) /saltybot/rc_channels (std_msgs/String JSON)
SENSORS (0x02) /saltybot/sensors (std_msgs/String JSON)
=======
RX telemetry (ESP32-S3 Jetson):
IMU /saltybot/imu (sensor_msgs/Imu) IMU /saltybot/imu (sensor_msgs/Imu)
BATTERY /saltybot/telemetry/battery (std_msgs/String JSON) BATTERY /saltybot/telemetry/battery (std_msgs/String JSON)
MOTOR_RPM /saltybot/telemetry/motor_rpm (std_msgs/String JSON) MOTOR_RPM /saltybot/telemetry/motor_rpm (std_msgs/String JSON)
ARM_STATE /saltybot/arm_state (std_msgs/String JSON) ARM_STATE /saltybot/arm_state (std_msgs/String JSON)
ERROR /saltybot/error (std_msgs/String JSON) ERROR /saltybot/error (std_msgs/String JSON)
All frames /diagnostics (diagnostic_msgs/DiagnosticArray) All frames /diagnostics (diagnostic_msgs/DiagnosticArray)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
TX to ESP32 IO: Auto-reconnect: USB disconnect is detected when serial.read() raises; node
LED_CMD (0x10) /saltybot/leds (std_msgs/String JSON) continuously retries at reconnect_delay interval.
OUTPUT_CMD (0x11) /saltybot/outputs (std_msgs/String JSON)
HEARTBEAT (0x20) sent every heartbeat_period (keep IO watchdog alive) This node owns /dev/can0 exclusively do NOT run alongside
serial_bridge_node or saltybot_cmd_node on the same port.
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
Parameters (config/stm32_cmd_params.yaml):
serial_port /dev/esp32-io
baud_rate 460800
reconnect_delay 2.0
heartbeat_period 0.2 (ESP32 IO watchdog fires at ~500 ms)
=======
Parameters (config/esp32_cmd_params.yaml): Parameters (config/esp32_cmd_params.yaml):
serial_port /dev/ttyACM0 serial_port /dev/can0
baud_rate 921600 baud_rate 460800
reconnect_delay 2.0 (seconds) reconnect_delay 2.0 (seconds)
heartbeat_period 0.2 (seconds) heartbeat_period 0.2 (seconds)
watchdog_timeout 0.5 (seconds no /cmd_vel send zero-speed) watchdog_timeout 0.5 (seconds no /cmd_vel send zero-speed)
speed_scale 1000.0 (linear.x m/s ESC units) speed_scale 1000.0 (linear.x m/s ESC units)
steer_scale -500.0 (angular.z rad/s ESC units, neg to flip convention) steer_scale -500.0 (angular.z rad/s ESC units, neg to flip convention)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import math
import threading import threading
import time import time
@ -71,82 +50,119 @@ from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
import serial import serial
from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue
from geometry_msgs.msg import Twist
from sensor_msgs.msg import Imu
from std_msgs.msg import String from std_msgs.msg import String
from std_srvs.srv import SetBool, Trigger
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
from .stm32_protocol import (
BAUD_RATE,
=======
from .esp32_protocol import ( from .esp32_protocol import (
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
FrameParser, FrameParser,
RcChannels, ImuFrame, BatteryFrame, MotorRpmFrame, ArmStateFrame, ErrorFrame,
SensorData, encode_heartbeat, encode_speed_steer, encode_arm, encode_set_mode,
encode_heartbeat, encode_pid_update,
encode_led_cmd,
encode_output_cmd,
) )
# ── Constants ─────────────────────────────────────────────────────────────────
class Stm32CmdNode(Node): IMU_FRAME_ID = "imu_link"
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py _ARM_LABEL = {0: "DISARMED", 1: "ARMED", 2: "TILT_FAULT"}
"""Orin ↔ ESP32-S3 IO auxiliary bridge node."""
=======
"""Binary-framed Jetson↔ESP32-S3 bridge node.""" def _clamp(v: float, lo: float, hi: float) -> float:
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py return max(lo, min(hi, v))
# ── Node ──────────────────────────────────────────────────────────────────────
class Esp32CmdNode(Node):
"""Binary-framed Jetson↔ESP32-S3 BALANCE bridge node."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__("esp32_cmd_node") super().__init__("esp32_cmd_node")
# ── Parameters ──────────────────────────────────────────────────── # ── Parameters ────────────────────────────────────────────────────────
self.declare_parameter("serial_port", "/dev/esp32-io") self.declare_parameter("serial_port", "/dev/can0")
self.declare_parameter("baud_rate", BAUD_RATE) self.declare_parameter("baud_rate", 460800)
self.declare_parameter("reconnect_delay", 2.0) self.declare_parameter("reconnect_delay", 2.0)
self.declare_parameter("heartbeat_period", 0.2) self.declare_parameter("heartbeat_period", 0.2)
self.declare_parameter("watchdog_timeout", 0.5)
self.declare_parameter("speed_scale", 1000.0)
self.declare_parameter("steer_scale", -500.0)
self._port_name = self.get_parameter("serial_port").value port = self.get_parameter("serial_port").value
self._baud = self.get_parameter("baud_rate").value baud = self.get_parameter("baud_rate").value
self._reconnect_delay = self.get_parameter("reconnect_delay").value self._reconnect_delay = self.get_parameter("reconnect_delay").value
self._hb_period = self.get_parameter("heartbeat_period").value self._hb_period = self.get_parameter("heartbeat_period").value
self._wd_timeout = self.get_parameter("watchdog_timeout").value
self._speed_scale = self.get_parameter("speed_scale").value
self._steer_scale = self.get_parameter("steer_scale").value
# ── QoS ─────────────────────────────────────────────────────────── # ── QoS ───────────────────────────────────────────────────────────────
sensor_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST, depth=10,
)
rel_qos = QoSProfile( rel_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE, reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST, depth=10, history=HistoryPolicy.KEEP_LAST, depth=10,
) )
# ── Publishers ──────────────────────────────────────────────────── # ── Publishers ────────────────────────────────────────────────────────
self._rc_pub = self.create_publisher(String, "/saltybot/rc_channels", rel_qos) self._imu_pub = self.create_publisher(Imu, "/saltybot/imu", sensor_qos)
self._sens_pub = self.create_publisher(String, "/saltybot/sensors", rel_qos) self._arm_pub = self.create_publisher(String, "/saltybot/arm_state", rel_qos)
self._diag_pub = self.create_publisher(DiagnosticArray, "/diagnostics", rel_qos) self._error_pub = self.create_publisher(String, "/saltybot/error", rel_qos)
self._battery_pub = self.create_publisher(String, "/saltybot/telemetry/battery", rel_qos)
self._rpm_pub = self.create_publisher(String, "/saltybot/telemetry/motor_rpm", rel_qos)
self._diag_pub = self.create_publisher(DiagnosticArray, "/diagnostics", rel_qos)
# ── Subscriptions ───────────────────────────────────────────────── # ── Subscribers ───────────────────────────────────────────────────────
self.create_subscription(String, "/saltybot/leds", self._on_leds, rel_qos) self._cmd_vel_sub = self.create_subscription(
self.create_subscription(String, "/saltybot/outputs", self._on_outputs, rel_qos) Twist, "/cmd_vel", self._on_cmd_vel, rel_qos,
)
# ── Serial state ────────────────────────────────────────────────── self._pid_sub = self.create_subscription(
self._ser: serial.Serial | None = None String, "/saltybot/pid_update", self._on_pid_update, rel_qos,
self._ser_lock = threading.Lock()
self._parser = FrameParser()
self._rx_count = 0
# ── Open serial and start timers ──────────────────────────────────
self._open_serial()
self._read_timer = self.create_timer(0.005, self._read_cb)
self._hb_timer = self.create_timer(self._hb_period, self._heartbeat_cb)
self._diag_timer = self.create_timer(1.0, self._publish_diagnostics)
self.get_logger().info(
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
f"stm32_cmd_node started — {self._port_name} @ {self._baud} baud"
=======
f"esp32_cmd_node started — {port} @ {baud} baud | "
f"HB {int(self._hb_period * 1000)}ms | WD {int(self._wd_timeout * 1000)}ms"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
) )
# ── Serial management ───────────────────────────────────────────────── # ── Services ──────────────────────────────────────────────────────────
self._arm_srv = self.create_service(SetBool, "/saltybot/arm", self._svc_arm)
self._mode_srv = self.create_service(SetBool, "/saltybot/set_mode", self._svc_set_mode)
# ── Serial state ──────────────────────────────────────────────────────
self._port_name = port
self._baud = baud
self._ser: serial.Serial | None = None
self._ser_lock = threading.Lock()
self._parser = FrameParser()
# ── TX state ──────────────────────────────────────────────────────────
self._last_speed = 0
self._last_steer = 0
self._last_cmd_t = time.monotonic()
self._watchdog_sent = False # tracks whether we already sent zero
# ── Diagnostics state ──────────────────────────────────────────────────
self._last_arm_state = -1
self._last_battery_mv = 0
self._rx_frame_count = 0
# ── Open serial and start timers ──────────────────────────────────────
self._open_serial()
# Read at 200 Hz (serial RX thread is better, but timer keeps ROS2 integration clean)
self._read_timer = self.create_timer(0.005, self._read_cb)
# Heartbeat TX
self._hb_timer = self.create_timer(self._hb_period, self._heartbeat_cb)
# Watchdog check (fires at 2× watchdog_timeout for quick detection)
self._wd_timer = self.create_timer(self._wd_timeout / 2, self._watchdog_cb)
# Periodic diagnostics
self._diag_timer = self.create_timer(1.0, self._publish_diagnostics)
self.get_logger().info(
f"esp32_cmd_node started — {port} @ {baud} baud | "
f"HB {int(self._hb_period * 1000)}ms | WD {int(self._wd_timeout * 1000)}ms"
)
# ── Serial management ─────────────────────────────────────────────────────
def _open_serial(self) -> bool: def _open_serial(self) -> bool:
with self._ser_lock: with self._ser_lock:
@ -154,7 +170,7 @@ class Stm32CmdNode(Node):
self._ser = serial.Serial( self._ser = serial.Serial(
port=self._port_name, port=self._port_name,
baudrate=self._baud, baudrate=self._baud,
timeout=0.005, timeout=0.005, # non-blocking reads
write_timeout=0.1, write_timeout=0.1,
) )
self._ser.reset_input_buffer() self._ser.reset_input_buffer()
@ -169,7 +185,17 @@ class Stm32CmdNode(Node):
self._ser = None self._ser = None
return False return False
def _close_serial(self) -> None:
with self._ser_lock:
if self._ser and self._ser.is_open:
try:
self._ser.close()
except Exception:
pass
self._ser = None
def _write(self, data: bytes) -> bool: def _write(self, data: bytes) -> bool:
"""Thread-safe serial write. Returns False if port is not open."""
with self._ser_lock: with self._ser_lock:
if self._ser is None or not self._ser.is_open: if self._ser is None or not self._ser.is_open:
return False return False
@ -181,15 +207,16 @@ class Stm32CmdNode(Node):
self._ser = None self._ser = None
return False return False
# ── RX ──────────────────────────────────────────────────────────────── # ── RX — read callback ────────────────────────────────────────────────────
def _read_cb(self) -> None: def _read_cb(self) -> None:
"""Read bytes from serial and feed them to the frame parser."""
raw: bytes | None = None raw: bytes | None = None
reconnect = False reconnect_needed = False
with self._ser_lock: with self._ser_lock:
if self._ser is None or not self._ser.is_open: if self._ser is None or not self._ser.is_open:
reconnect = True reconnect_needed = True
else: else:
try: try:
n = self._ser.in_waiting n = self._ser.in_waiting
@ -198,9 +225,9 @@ class Stm32CmdNode(Node):
except serial.SerialException as exc: except serial.SerialException as exc:
self.get_logger().error(f"Serial read error: {exc}") self.get_logger().error(f"Serial read error: {exc}")
self._ser = None self._ser = None
reconnect = True reconnect_needed = True
if reconnect: if reconnect_needed:
self.get_logger().warn( self.get_logger().warn(
"Serial disconnected — will retry", "Serial disconnected — will retry",
throttle_duration_sec=self._reconnect_delay, throttle_duration_sec=self._reconnect_delay,
@ -213,41 +240,24 @@ class Stm32CmdNode(Node):
return return
for byte in raw: for byte in raw:
msg = self._parser.feed(byte) frame = self._parser.feed(byte)
if msg is not None: if frame is not None:
self._rx_count += 1 self._rx_frame_count += 1
self._dispatch(msg) self._dispatch_frame(frame)
def _dispatch(self, msg) -> None: def _dispatch_frame(self, frame) -> None:
"""Route a decoded frame to the appropriate publisher."""
now = self.get_clock().now().to_msg() now = self.get_clock().now().to_msg()
ts = f"{now.sec}.{now.nanosec:09d}"
if isinstance(msg, RcChannels): if isinstance(frame, ImuFrame):
out = String() self._publish_imu(frame, now)
out.data = json.dumps({
"channels": msg.channels,
"source": msg.source,
"ts": ts,
})
self._rc_pub.publish(out)
elif isinstance(msg, SensorData): elif isinstance(frame, BatteryFrame):
out = String() self._publish_battery(frame, now)
out.data = json.dumps({
"pressure_pa": msg.pressure_pa,
"temperature_c": msg.temperature_c,
"tof_mm": msg.tof_mm,
"ts": ts,
})
self._sens_pub.publish(out)
elif isinstance(msg, tuple): elif isinstance(frame, MotorRpmFrame):
type_code, _ = msg self._publish_motor_rpm(frame, now)
self.get_logger().debug(f"Unknown inter-board type 0x{type_code:02X}")
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
# ── TX ────────────────────────────────────────────────────────────────
=======
elif isinstance(frame, ArmStateFrame): elif isinstance(frame, ArmStateFrame):
self._publish_arm_state(frame, now) self._publish_arm_state(frame, now)
@ -358,85 +368,108 @@ class Stm32CmdNode(Node):
"SPEED_STEER dropped — serial not open", "SPEED_STEER dropped — serial not open",
throttle_duration_sec=2.0, throttle_duration_sec=2.0,
) )
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
def _heartbeat_cb(self) -> None: def _heartbeat_cb(self) -> None:
"""Send HEARTBEAT every heartbeat_period (default 200ms)."""
self._write(encode_heartbeat()) self._write(encode_heartbeat())
def _on_leds(self, msg: String) -> None: def _watchdog_cb(self) -> None:
"""Parse JSON {"pattern":N,"r":R,"g":G,"b":B} and send LED_CMD.""" """Send zero-speed if /cmd_vel silent for watchdog_timeout seconds."""
try: if time.monotonic() - self._last_cmd_t >= self._wd_timeout:
d = json.loads(msg.data) if not self._watchdog_sent:
frame = encode_led_cmd( self.get_logger().warn(
int(d.get("pattern", 0)), f"No /cmd_vel for {self._wd_timeout:.1f}s — sending zero-speed"
int(d.get("r", 0)), )
int(d.get("g", 0)), self._watchdog_sent = True
int(d.get("b", 0)), self._last_speed = 0
) self._last_steer = 0
except (ValueError, KeyError, json.JSONDecodeError) as exc: self._write(encode_speed_steer(0, 0))
self.get_logger().error(f"Bad /saltybot/leds JSON: {exc}")
return
self._write(frame)
def _on_outputs(self, msg: String) -> None: def _on_pid_update(self, msg: String) -> None:
"""Parse JSON {"horn":bool,"buzzer":bool,"headlight":0-255,"fan":0-255}.""" """Parse JSON /saltybot/pid_update and send PID_UPDATE frame."""
try: try:
d = json.loads(msg.data) data = json.loads(msg.data)
frame = encode_output_cmd( kp = float(data["kp"])
bool(d.get("horn", False)), ki = float(data["ki"])
bool(d.get("buzzer", False)), kd = float(data["kd"])
int(d.get("headlight", 0)),
int(d.get("fan", 0)),
)
except (ValueError, KeyError, json.JSONDecodeError) as exc: except (ValueError, KeyError, json.JSONDecodeError) as exc:
self.get_logger().error(f"Bad /saltybot/outputs JSON: {exc}") self.get_logger().error(f"Bad PID update JSON: {exc}")
return return
self._write(frame) frame = encode_pid_update(kp, ki, kd)
if self._write(frame):
self.get_logger().info(f"PID update: kp={kp}, ki={ki}, kd={kd}")
else:
self.get_logger().warn("PID_UPDATE dropped — serial not open")
# ── Diagnostics ─────────────────────────────────────────────────────── # ── Services ──────────────────────────────────────────────────────────────
def _svc_arm(self, request: SetBool.Request, response: SetBool.Response):
"""SetBool(True) = arm, SetBool(False) = disarm."""
arm = request.data
frame = encode_arm(arm)
ok = self._write(frame)
response.success = ok
response.message = ("ARMED" if arm else "DISARMED") if ok else "serial not open"
self.get_logger().info(
f"ARM service: {'arm' if arm else 'disarm'}{'sent' if ok else 'FAILED'}"
)
return response
def _svc_set_mode(self, request: SetBool.Request, response: SetBool.Response):
"""SetBool: data maps to mode byte (True=1, False=0)."""
mode = 1 if request.data else 0
frame = encode_set_mode(mode)
ok = self._write(frame)
response.success = ok
response.message = f"mode={mode}" if ok else "serial not open"
return response
# ── Diagnostics ───────────────────────────────────────────────────────────
def _publish_diagnostics(self) -> None: def _publish_diagnostics(self) -> None:
diag = DiagnosticArray() diag = DiagnosticArray()
diag.header.stamp = self.get_clock().now().to_msg() diag.header.stamp = self.get_clock().now().to_msg()
status = DiagnosticStatus()
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
status.name = "saltybot/esp32_io_bridge"
status.hardware_id = "esp32-s3-io"
=======
status.name = "saltybot/esp32_cmd_node"
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py status = DiagnosticStatus()
status.name = "saltybot/esp32_cmd_node"
status.hardware_id = "esp32s3_balance"
port_ok = self._ser is not None and self._ser.is_open port_ok = self._ser is not None and self._ser.is_open
status.level = DiagnosticStatus.OK if port_ok else DiagnosticStatus.ERROR if port_ok:
status.message = "Serial OK" if port_ok else f"Disconnected: {self._port_name}" status.level = DiagnosticStatus.OK
status.values = [ status.message = "Serial OK"
KeyValue(key="serial_port", value=self._port_name), else:
KeyValue(key="baud_rate", value=str(self._baud)), status.level = DiagnosticStatus.ERROR
KeyValue(key="port_open", value=str(port_ok)), status.message = f"Serial disconnected: {self._port_name}"
KeyValue(key="rx_frames", value=str(self._rx_count)),
KeyValue(key="rx_errors", value=str(self._parser.frames_error)), wd_age = time.monotonic() - self._last_cmd_t
status.values = [
KeyValue(key="serial_port", value=self._port_name),
KeyValue(key="port_open", value=str(port_ok)),
KeyValue(key="rx_frames", value=str(self._rx_frame_count)),
KeyValue(key="rx_errors", value=str(self._parser.frames_error)),
KeyValue(key="last_speed", value=str(self._last_speed)),
KeyValue(key="last_steer", value=str(self._last_steer)),
KeyValue(key="cmd_vel_age_s", value=f"{wd_age:.2f}"),
KeyValue(key="battery_mv", value=str(self._last_battery_mv)),
KeyValue(key="arm_state", value=_ARM_LABEL.get(self._last_arm_state, "?")),
] ]
diag.status.append(status) diag.status.append(status)
self._diag_pub.publish(diag) self._diag_pub.publish(diag)
# ── Lifecycle ───────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────
def destroy_node(self) -> None: def destroy_node(self) -> None:
self._write(encode_heartbeat(state=0)) # Send zero-speed + disarm on shutdown
with self._ser_lock: self._write(encode_speed_steer(0, 0))
if self._ser and self._ser.is_open: self._write(encode_arm(False))
try: self._close_serial()
self._ser.close()
except Exception:
pass
self._ser = None
super().destroy_node() super().destroy_node()
def main(args=None) -> None: def main(args=None) -> None:
rclpy.init(args=args) rclpy.init(args=args)
node = Stm32CmdNode() node = Esp32CmdNode()
try: try:
rclpy.spin(node) rclpy.spin(node)
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -1,7 +1,7 @@
"""esp32_protocol.py — Binary frame codec for Jetson↔ESP32-S3 communication. """esp32_protocol.py — Binary frame codec for Jetson↔ESP32-S3 BALANCE communication.
Issue #119: defines the binary serial protocol between the Jetson Orin Nano Super and the Issue #119: defines the binary serial protocol between the Jetson Orin Nano Super and the
ESP32-S3 ESP32-S3 BALANCE over USB CDC @ 921600 baud. ESP32-S3 BALANCE board over inter-board UART @ 460800 baud.
Frame layout (all multi-byte fields are big-endian): Frame layout (all multi-byte fields are big-endian):
@ -12,14 +12,14 @@ Frame layout (all multi-byte fields are big-endian):
CRC16 covers: TYPE + LEN + PAYLOAD (not STX, ETX, or CRC bytes themselves). CRC16 covers: TYPE + LEN + PAYLOAD (not STX, ETX, or CRC bytes themselves).
CRC algorithm: CCITT-16, polynomial=0x1021, init=0xFFFF, no final XOR. CRC algorithm: CCITT-16, polynomial=0x1021, init=0xFFFF, no final XOR.
Command types (Jetson ESP32-S3): Command types (Jetson ESP32-S3 BALANCE):
0x01 HEARTBEAT no payload (len=0) 0x01 HEARTBEAT no payload (len=0)
0x02 SPEED_STEER int16 speed + int16 steer (len=4) range: -1000..+1000 0x02 SPEED_STEER int16 speed + int16 steer (len=4) range: -1000..+1000
0x03 ARM uint8 (0=disarm, 1=arm) (len=1) 0x03 ARM uint8 (0=disarm, 1=arm) (len=1)
0x04 SET_MODE uint8 mode (len=1) 0x04 SET_MODE uint8 mode (len=1)
0x05 PID_UPDATE float32 kp + ki + kd (len=12) 0x05 PID_UPDATE float32 kp + ki + kd (len=12)
Telemetry types (ESP32-S3 Jetson): Telemetry types (ESP32-S3 BALANCE Jetson):
0x10 IMU int16×6: pitch,roll,yaw (×100 deg), ax,ay,az (×100 m/) (len=12) 0x10 IMU int16×6: pitch,roll,yaw (×100 deg), ax,ay,az (×100 m/) (len=12)
0x11 BATTERY uint16 voltage_mv + int16 current_ma + uint8 soc_pct (len=5) 0x11 BATTERY uint16 voltage_mv + int16 current_ma + uint8 soc_pct (len=5)
0x12 MOTOR_RPM int16 left_rpm + int16 right_rpm (len=4) 0x12 MOTOR_RPM int16 left_rpm + int16 right_rpm (len=4)
@ -27,11 +27,11 @@ Telemetry types (ESP32-S3 → Jetson):
0x14 ERROR uint8 error_code + uint8 subcode (len=2) 0x14 ERROR uint8 error_code + uint8 subcode (len=2)
Usage: Usage:
# Encoding (Jetson → ESP32-S3) # Encoding (Jetson → ESP32-S3 BALANCE)
frame = encode_speed_steer(300, -150) frame = encode_speed_steer(300, -150)
ser.write(frame) ser.write(frame)
# Decoding (ESP32-S3 → Jetson), one byte at a time # Decoding (ESP32-S3 BALANCE → Jetson), one byte at a time
parser = FrameParser() parser = FrameParser()
for byte in incoming_bytes: for byte in incoming_bytes:
result = parser.feed(byte) result = parser.feed(byte)
@ -87,7 +87,7 @@ class ImuFrame:
class BatteryFrame: class BatteryFrame:
voltage_mv: int # millivolts (e.g. 11100 = 11.1 V) voltage_mv: int # millivolts (e.g. 11100 = 11.1 V)
current_ma: int # milliamps (negative = charging) current_ma: int # milliamps (negative = charging)
soc_pct: int # state of charge 0100 (from ESP32-S3 fuel gauge or lookup) soc_pct: int # state of charge 0100 (from ESP32-S3 BALANCE fuel gauge or lookup)
@dataclass @dataclass
@ -183,7 +183,7 @@ class ParseError(Exception):
class FrameParser: class FrameParser:
"""Byte-by-byte streaming parser for ESP32-S3 telemetry frames. """Byte-by-byte streaming parser for ESP32-S3 BALANCE telemetry frames.
Feed individual bytes via feed(); returns a decoded TelemetryFrame (or raw Feed individual bytes via feed(); returns a decoded TelemetryFrame (or raw
bytes tuple) when a complete valid frame is received. bytes tuple) when a complete valid frame is received.

View File

@ -322,11 +322,7 @@ class SaltybotCanNode(Node):
diag.header.stamp = stamp diag.header.stamp = stamp
st = DiagnosticStatus() st = DiagnosticStatus()
st.name = "saltybot/balance_controller" st.name = "saltybot/balance_controller"
<<<<<<< HEAD st.hardware_id = "esp32s3_balance"
st.hardware_id = "esp32"
=======
st.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
st.message = state_label st.message = state_label
st.level = (DiagnosticStatus.OK if state == 1 else st.level = (DiagnosticStatus.OK if state == 1 else
DiagnosticStatus.WARN if state == 0 else DiagnosticStatus.WARN if state == 0 else

View File

@ -1,38 +1,20 @@
""" """
<<<<<<< HEAD saltybot_cmd_node full bidirectional STM32Jetson bridge
saltybot_cmd_node full bidirectional ESP32 BALANCEJetson bridge
=======
saltybot_cmd_node full bidirectional ESP32-S3Jetson bridge
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Combines telemetry RX (from serial_bridge_node) with drive command TX. Combines telemetry RX (from serial_bridge_node) with drive command TX.
Owns /dev/ttyACM0 exclusively do NOT run alongside serial_bridge_node. Owns /dev/ttyACM0 exclusively do NOT run alongside serial_bridge_node.
<<<<<<< HEAD RX path (50Hz from STM32):
RX path (50Hz from ESP32 BALANCE):
JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics
TX path: TX path:
/cmd_vel (geometry_msgs/Twist) C<speed>,<steer>\\n ESP32 BALANCE /cmd_vel (geometry_msgs/Twist) C<speed>,<steer>\\n STM32
Heartbeat timer (200ms) H\\n ESP32 BALANCE Heartbeat timer (200ms) H\\n STM32
Protocol: Protocol:
H\\n heartbeat. ESP32 BALANCE reverts steer to 0 if gap > 500ms. H\\n heartbeat. STM32 reverts steer to 0 if gap > 500ms.
C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers. C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers.
C command also refreshes ESP32 BALANCE heartbeat timer. C command also refreshes STM32 heartbeat timer.
=======
RX path (50Hz from ESP32-S3):
JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics
TX path:
/cmd_vel (geometry_msgs/Twist) C<speed>,<steer>\\n ESP32-S3
Heartbeat timer (200ms) H\\n ESP32-S3
Protocol:
H\\n heartbeat. ESP32-S3 reverts steer to 0 if gap > 500ms.
C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers.
C command also refreshes ESP32-S3 heartbeat timer.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Twist mapping (configurable via ROS2 params): Twist mapping (configurable via ROS2 params):
speed = clamp(linear.x * speed_scale, -1000, 1000) speed = clamp(linear.x * speed_scale, -1000, 1000)
@ -118,11 +100,7 @@ class SaltybotCmdNode(Node):
self._open_serial() self._open_serial()
# ── Timers ──────────────────────────────────────────────────────────── # ── Timers ────────────────────────────────────────────────────────────
<<<<<<< HEAD # Telemetry read at 100Hz (STM32 sends at 50Hz)
# Telemetry read at 100Hz (ESP32 BALANCE sends at 50Hz)
=======
# Telemetry read at 100Hz (ESP32-S3 sends at 50Hz)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self._read_timer = self.create_timer(0.01, self._read_cb) self._read_timer = self.create_timer(0.01, self._read_cb)
# Heartbeat TX at configured period (default 200ms) # Heartbeat TX at configured period (default 200ms)
self._hb_timer = self.create_timer(self._hb_period, self._heartbeat_cb) self._hb_timer = self.create_timer(self._hb_period, self._heartbeat_cb)
@ -288,11 +266,7 @@ class SaltybotCmdNode(Node):
diag.header.stamp = stamp diag.header.stamp = stamp
status = DiagnosticStatus() status = DiagnosticStatus()
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
=======
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
status.message = state_label status.message = state_label
if state == 1: if state == 1:
status.level = DiagnosticStatus.OK status.level = DiagnosticStatus.OK
@ -320,19 +294,11 @@ class SaltybotCmdNode(Node):
status = DiagnosticStatus() status = DiagnosticStatus()
status.level = DiagnosticStatus.ERROR status.level = DiagnosticStatus.ERROR
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
status.message = f"IMU fault errno={errno}" status.message = f"IMU fault errno={errno}"
diag.status.append(status) diag.status.append(status)
self._diag_pub.publish(diag) self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE IMU fault: errno={errno}") self.get_logger().error(f"STM32 IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32-S3 IMU fault: errno={errno}")
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
# ── TX — command send ───────────────────────────────────────────────────── # ── TX — command send ─────────────────────────────────────────────────────
@ -350,11 +316,7 @@ class SaltybotCmdNode(Node):
) )
def _heartbeat_cb(self): def _heartbeat_cb(self):
<<<<<<< HEAD """Send H\\n heartbeat. STM32 reverts steer to 0 if gap > 500ms."""
"""Send H\\n heartbeat. ESP32 BALANCE reverts steer to 0 if gap > 500ms."""
=======
"""Send H\\n heartbeat. ESP32-S3 reverts steer to 0 if gap > 500ms."""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self._write(b"H\n") self._write(b"H\n")
# ── Lifecycle ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────

View File

@ -1,10 +1,6 @@
""" """
saltybot_bridge serial_bridge_node saltybot_bridge serial_bridge_node
<<<<<<< HEAD ESP32-S3 BALANCE ROS2 topic publisher (inter-board UART)
ESP32 USB CDC ROS2 topic publisher
=======
ESP32-S3 USB CDC ROS2 topic publisher
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Telemetry frame (50 Hz, newline-delimited JSON): Telemetry frame (50 Hz, newline-delimited JSON):
{"p":<pitch×10>,"r":<roll×10>,"e":<err×10>,"ig":<integral×10>, {"p":<pitch×10>,"r":<roll×10>,"e":<err×10>,"ig":<integral×10>,
@ -33,11 +29,7 @@ from sensor_msgs.msg import Imu
from std_msgs.msg import String from std_msgs.msg import String
from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue
<<<<<<< HEAD # Balance state labels matching STM32 balance_state_t enum
# Balance state labels matching ESP32 BALANCE balance_state_t enum
=======
# Balance state labels matching ESP32-S3 balance_state_t enum
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
_STATE_LABEL = {0: "DISARMED", 1: "ARMED", 2: "TILT_FAULT"} _STATE_LABEL = {0: "DISARMED", 1: "ARMED", 2: "TILT_FAULT"}
# Sensor frame_id published in Imu header # Sensor frame_id published in Imu header
@ -46,7 +38,7 @@ IMU_FRAME_ID = "imu_link"
class SerialBridgeNode(Node): class SerialBridgeNode(Node):
def __init__(self): def __init__(self):
super().__init__("esp32_serial_bridge") super().__init__("stm32_serial_bridge")
# ── Parameters ──────────────────────────────────────────────────────── # ── Parameters ────────────────────────────────────────────────────────
self.declare_parameter("serial_port", "/dev/ttyACM0") self.declare_parameter("serial_port", "/dev/ttyACM0")
@ -91,11 +83,7 @@ class SerialBridgeNode(Node):
# ── Open serial and start read timer ────────────────────────────────── # ── Open serial and start read timer ──────────────────────────────────
self._open_serial() self._open_serial()
<<<<<<< HEAD # Poll at 100 Hz — STM32 sends at 50 Hz, so we never miss a frame
# Poll at 100 Hz — ESP32 BALANCE sends at 50 Hz, so we never miss a frame
=======
# Poll at 100 Hz — ESP32-S3 sends at 50 Hz, so we never miss a frame
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self._timer = self.create_timer(0.01, self._read_cb) self._timer = self.create_timer(0.01, self._read_cb)
self.get_logger().info( self.get_logger().info(
@ -129,11 +117,7 @@ class SerialBridgeNode(Node):
def write_serial(self, data: bytes) -> bool: def write_serial(self, data: bytes) -> bool:
""" """
<<<<<<< HEAD Send raw bytes to STM32 over the open serial port.
Send raw bytes to ESP32 BALANCE over the open serial port.
=======
Send raw bytes to ESP32-S3 over the open serial port.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Returns False if port is not open (caller should handle gracefully). Returns False if port is not open (caller should handle gracefully).
Note: for bidirectional use prefer saltybot_cmd_node which owns TX natively. Note: for bidirectional use prefer saltybot_cmd_node which owns TX natively.
""" """
@ -222,11 +206,7 @@ class SerialBridgeNode(Node):
""" """
Publish sensor_msgs/Imu. Publish sensor_msgs/Imu.
<<<<<<< HEAD The STM32 IMU gives Euler angles (pitch/roll from accelerometer+gyro
The ESP32 BALANCE IMU gives Euler angles (pitch/roll from accelerometer+gyro
=======
The ESP32-S3 IMU gives Euler angles (pitch/roll from accelerometer+gyro
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
fusion, yaw from gyro integration). We publish them as angular_velocity fusion, yaw from gyro integration). We publish them as angular_velocity
for immediate use by slam_toolbox / robot_localization. for immediate use by slam_toolbox / robot_localization.
@ -284,11 +264,7 @@ class SerialBridgeNode(Node):
diag.header.stamp = stamp diag.header.stamp = stamp
status = DiagnosticStatus() status = DiagnosticStatus()
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
=======
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
status.message = state_label status.message = state_label
if state == 1: # ARMED if state == 1: # ARMED
@ -317,19 +293,11 @@ class SerialBridgeNode(Node):
status = DiagnosticStatus() status = DiagnosticStatus()
status.level = DiagnosticStatus.ERROR status.level = DiagnosticStatus.ERROR
status.name = "saltybot/balance_controller" status.name = "saltybot/balance_controller"
<<<<<<< HEAD status.hardware_id = "esp32s3_balance"
status.hardware_id = "esp32"
status.message = f"IMU fault errno={errno}" status.message = f"IMU fault errno={errno}"
diag.status.append(status) diag.status.append(status)
self._diag_pub.publish(diag) self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE reported IMU fault: errno={errno}") self.get_logger().error(f"STM32 reported IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32-S3 reported IMU fault: errno={errno}")
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
def destroy_node(self): def destroy_node(self):
self._close_serial() self._close_serial()

View File

@ -61,12 +61,8 @@ kill %1
### Core System Components ### Core System Components
- Robot Description (URDF/TF tree) - Robot Description (URDF/TF tree)
<<<<<<< HEAD - ESP32-S3 CAN Bridge
- ESP32 Serial Bridge - cmd_vel Bridge
=======
- ESP32-S3 Serial Bridge
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- cmd_vel Bridge
- Rosbridge WebSocket - Rosbridge WebSocket
### Sensors ### Sensors
@ -129,15 +125,11 @@ free -h
### cmd_vel bridge not responding ### cmd_vel bridge not responding
```bash ```bash
<<<<<<< HEAD # Verify CAN bridge is running first
# Verify ESP32 bridge is running first
=======
# Verify ESP32-S3 bridge is running first
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
ros2 node list | grep bridge ros2 node list | grep bridge
# Check serial port # Check CAN interface
ls -l /dev/esp32-bridge ip link show can0
``` ```
## Performance Baseline ## Performance Baseline

View File

@ -6,13 +6,13 @@ and VESC telemetry.
CAN message layout CAN message layout
------------------ ------------------
Command frames (Orin ESP32-S3 BALANCE / VESC): Command frames (Orin ESP32-S3 BALANCE / VESC):
MAMBA_CMD_VELOCITY 0x100 8 bytes left_speed (f32, m/s) | right_speed (f32, m/s) BALANCE_CMD_VELOCITY 0x100 8 bytes left_speed (f32, m/s) | right_speed (f32, m/s)
MAMBA_CMD_MODE 0x101 1 byte mode (0=idle, 1=drive, 2=estop) BALANCE_CMD_MODE 0x101 1 byte mode (0=idle, 1=drive, 2=estop)
MAMBA_CMD_ESTOP 0x102 1 byte 0x01 = stop BALANCE_CMD_ESTOP 0x102 1 byte 0x01 = stop
Telemetry frames (ESP32-S3 BALANCE Orin): Telemetry frames (ESP32-S3 BALANCE Orin):
MAMBA_TELEM_IMU 0x200 24 bytes accel_x, accel_y, accel_z, gyro_x, gyro_y, gyro_z (f32 each) BALANCE_TELEM_IMU 0x200 24 bytes accel_x, accel_y, accel_z, gyro_x, gyro_y, gyro_z (f32 each)
MAMBA_TELEM_BATTERY 0x201 8 bytes voltage (f32, V) | current (f32, A) BALANCE_TELEM_BATTERY 0x201 8 bytes voltage (f32, V) | current (f32, A)
VESC telemetry frame (VESC Orin): VESC telemetry frame (VESC Orin):
VESC_TELEM_STATE 0x300 16 bytes erpm (f32) | duty (f32) | voltage (f32) | current (f32) VESC_TELEM_STATE 0x300 16 bytes erpm (f32) | duty (f32) | voltage (f32) | current (f32)
@ -30,12 +30,12 @@ from typing import Tuple
# CAN message IDs # CAN message IDs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MAMBA_CMD_VELOCITY: int = 0x100 BALANCE_CMD_VELOCITY: int = 0x100
MAMBA_CMD_MODE: int = 0x101 BALANCE_CMD_MODE: int = 0x101
MAMBA_CMD_ESTOP: int = 0x102 BALANCE_CMD_ESTOP: int = 0x102
MAMBA_TELEM_IMU: int = 0x200 BALANCE_TELEM_IMU: int = 0x200
MAMBA_TELEM_BATTERY: int = 0x201 BALANCE_TELEM_BATTERY: int = 0x201
VESC_TELEM_STATE: int = 0x300 VESC_TELEM_STATE: int = 0x300
ORIN_CAN_ID_PID_SET: int = 0x305 ORIN_CAN_ID_PID_SET: int = 0x305
@ -56,7 +56,7 @@ MODE_ESTOP: int = 2
@dataclass @dataclass
class ImuTelemetry: class ImuTelemetry:
"""Decoded IMU telemetry from ESP32-S3 BALANCE (MAMBA_TELEM_IMU).""" """Decoded IMU telemetry from ESP32-S3 BALANCE (BALANCE_TELEM_IMU)."""
accel_x: float = 0.0 # m/s² accel_x: float = 0.0 # m/s²
accel_y: float = 0.0 accel_y: float = 0.0
@ -68,7 +68,7 @@ class ImuTelemetry:
@dataclass @dataclass
class BatteryTelemetry: class BatteryTelemetry:
"""Decoded battery telemetry from ESP32-S3 BALANCE (MAMBA_TELEM_BATTERY).""" """Decoded battery telemetry from ESP32-S3 BALANCE (BALANCE_TELEM_BATTERY)."""
voltage: float = 0.0 # V voltage: float = 0.0 # V
current: float = 0.0 # A current: float = 0.0 # A
@ -106,7 +106,7 @@ _FMT_VESC = ">ffff" # 4 × float32
def encode_velocity_cmd(left_mps: float, right_mps: float) -> bytes: def encode_velocity_cmd(left_mps: float, right_mps: float) -> bytes:
""" """
Encode a MAMBA_CMD_VELOCITY payload. Encode a BALANCE_CMD_VELOCITY payload.
Parameters Parameters
---------- ----------
@ -122,7 +122,7 @@ def encode_velocity_cmd(left_mps: float, right_mps: float) -> bytes:
def encode_mode_cmd(mode: int) -> bytes: def encode_mode_cmd(mode: int) -> bytes:
""" """
Encode a MAMBA_CMD_MODE payload. Encode a BALANCE_CMD_MODE payload.
Parameters Parameters
---------- ----------
@ -139,7 +139,7 @@ def encode_mode_cmd(mode: int) -> bytes:
def encode_estop_cmd(stop: bool = True) -> bytes: def encode_estop_cmd(stop: bool = True) -> bytes:
""" """
Encode a MAMBA_CMD_ESTOP payload. Encode a BALANCE_CMD_ESTOP payload.
Parameters Parameters
---------- ----------
@ -165,7 +165,7 @@ def encode_pid_set_cmd(kp: float, ki: float, kd: float) -> bytes:
def decode_imu_telem(data: bytes) -> ImuTelemetry: def decode_imu_telem(data: bytes) -> ImuTelemetry:
""" """
Decode a MAMBA_TELEM_IMU payload. Decode a BALANCE_TELEM_IMU payload.
Parameters Parameters
---------- ----------
@ -188,7 +188,7 @@ def decode_imu_telem(data: bytes) -> ImuTelemetry:
def decode_battery_telem(data: bytes) -> BatteryTelemetry: def decode_battery_telem(data: bytes) -> BatteryTelemetry:
""" """
Decode a MAMBA_TELEM_BATTERY payload. Decode a BALANCE_TELEM_BATTERY payload.
Parameters Parameters
---------- ----------

View File

@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
can_bridge_node.py ROS2 node bridging the SaltyBot Orin to the ESP32-S3 BALANCE motor can_bridge_node.py ROS2 node bridging the SaltyBot Orin to the ESP32-S3 BALANCE
controller and VESC motor controllers over CAN bus. controller and VESC motor controllers over CAN bus.
Spec: docs/SAUL-TEE-SYSTEM-REFERENCE.md §6 (2026-04-04) The node opens the SocketCAN interface (slcan0 by default), spawns a background
reader thread to process incoming telemetry, and exposes the following interface:
Subscriptions Subscriptions
------------- -------------
@ -18,15 +19,9 @@ Publications
/can/vesc/right/state std_msgs/Float32MultiArray Right VESC state /can/vesc/right/state std_msgs/Float32MultiArray Right VESC state
/can/connection_status std_msgs/String "connected" | "disconnected" /can/connection_status std_msgs/String "connected" | "disconnected"
Parameters Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/674
----------
can_interface str CAN socket name (default: slcan0)
speed_scale float /cmd_vel linear.x (m/s) motor units (default: 1000.0)
steer_scale float /cmd_vel angular.z (rad/s) motor units (default: -500.0)
command_timeout_s float watchdog zero-vel threshold (default: 0.5)
""" """
import json
import threading import threading
import time import time
from typing import Optional from typing import Optional
@ -35,32 +30,36 @@ import can
import rclpy import rclpy
from geometry_msgs.msg import Twist from geometry_msgs.msg import Twist
from rclpy.node import Node from rclpy.node import Node
from sensor_msgs.msg import BatteryState from rcl_interfaces.msg import SetParametersResult
from sensor_msgs.msg import BatteryState, Imu
from std_msgs.msg import Bool, Float32MultiArray, String from std_msgs.msg import Bool, Float32MultiArray, String
from saltybot_can_bridge.balance_protocol import ( from saltybot_can_bridge.balance_protocol import (
MAMBA_CMD_ESTOP, BALANCE_CMD_ESTOP,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_TELEM_BATTERY, BALANCE_TELEM_BATTERY,
MAMBA_TELEM_IMU, BALANCE_TELEM_IMU,
VESC_TELEM_STATE, VESC_TELEM_STATE,
ORIN_CAN_ID_FC_PID_ACK, ORIN_CAN_ID_FC_PID_ACK,
ORIN_CAN_ID_PID_SET, ORIN_CAN_ID_PID_SET,
MODE_DRIVE, MODE_DRIVE,
MODE_ESTOP,
MODE_IDLE, MODE_IDLE,
encode_drive_cmd,
encode_arm_cmd,
encode_estop_cmd, encode_estop_cmd,
decode_attitude, encode_mode_cmd,
decode_battery, encode_velocity_cmd,
decode_vesc_status1, encode_pid_set_cmd,
decode_battery_telem,
decode_imu_telem,
decode_pid_ack,
decode_vesc_state,
) )
# Reconnect attempt interval when CAN bus is lost # Reconnect attempt interval when CAN bus is lost
_RECONNECT_INTERVAL_S: float = 5.0 _RECONNECT_INTERVAL_S: float = 5.0
# Watchdog tick rate (Hz); sends zero DRIVE when /cmd_vel is silent # Watchdog timer tick rate (Hz)
_WATCHDOG_HZ: float = 10.0 _WATCHDOG_HZ: float = 10.0
@ -71,41 +70,50 @@ class CanBridgeNode(Node):
super().__init__("can_bridge_node") super().__init__("can_bridge_node")
# ── Parameters ──────────────────────────────────────────────────── # ── Parameters ────────────────────────────────────────────────────
self.declare_parameter("can_interface", "slcan0") self.declare_parameter("can_interface", "can0")
self.declare_parameter("left_vesc_can_id", VESC_LEFT_ID) self.declare_parameter("left_vesc_can_id", 68)
self.declare_parameter("right_vesc_can_id", VESC_RIGHT_ID) self.declare_parameter("right_vesc_can_id", 56)
self.declare_parameter("speed_scale", 1000.0) self.declare_parameter("balance_can_id", 1)
self.declare_parameter("steer_scale", -500.0)
self.declare_parameter("command_timeout_s", 0.5) self.declare_parameter("command_timeout_s", 0.5)
self.declare_parameter("pid/kp", 0.0)
self.declare_parameter("pid/ki", 0.0)
self.declare_parameter("pid/kd", 0.0)
self._iface = self.get_parameter("can_interface").value self._iface: str = self.get_parameter("can_interface").value
self._left_vesc_id = self.get_parameter("left_vesc_can_id").value self._left_vesc_id: int = self.get_parameter("left_vesc_can_id").value
self._right_vesc_id = self.get_parameter("right_vesc_can_id").value self._right_vesc_id: int = self.get_parameter("right_vesc_can_id").value
self._speed_scale = self.get_parameter("speed_scale").value self._balance_id: int = self.get_parameter("balance_can_id").value
self._steer_scale = self.get_parameter("steer_scale").value self._cmd_timeout: float = self.get_parameter("command_timeout_s").value
self._cmd_timeout = self.get_parameter("command_timeout_s").value self._pid_kp: float = self.get_parameter("pid/kp").value
self._pid_ki: float = self.get_parameter("pid/ki").value
self._pid_kd: float = self.get_parameter("pid/kd").value
# ── State ───────────────────────────────────────────────────────── # ── State ─────────────────────────────────────────────────────────
self._bus: Optional[can.BusABC] = None self._bus: Optional[can.BusABC] = None
self._connected: bool = False self._connected: bool = False
self._last_cmd_time: float = time.monotonic() self._last_cmd_time: float = time.monotonic()
self._lock = threading.Lock() self._lock = threading.Lock() # protects _bus / _connected
# ── Publishers ──────────────────────────────────────────────────── # ── Publishers ────────────────────────────────────────────────────
self._pub_attitude = self.create_publisher(String, "/saltybot/attitude", 10) self._pub_imu = self.create_publisher(Imu, "/can/imu", 10)
self._pub_balance = self.create_publisher(String, "/saltybot/balance_state", 10) self._pub_battery = self.create_publisher(BatteryState, "/can/battery", 10)
self._pub_battery = self.create_publisher(BatteryState, "/can/battery", 10) self._pub_vesc_left = self.create_publisher(
self._pub_vesc_left = self.create_publisher(Float32MultiArray,"/can/vesc/left/state", 10) Float32MultiArray, "/can/vesc/left/state", 10
self._pub_vesc_right= self.create_publisher(Float32MultiArray,"/can/vesc/right/state", 10) )
self._pub_status = self.create_publisher(String, "/can/connection_status", 10) self._pub_vesc_right = self.create_publisher(
Float32MultiArray, "/can/vesc/right/state", 10
)
self._pub_status = self.create_publisher(
String, "/can/connection_status", 10
)
# ── Subscriptions ───────────────────────────────────────────────── # ── Subscriptions ─────────────────────────────────────────────────
self.create_subscription(Twist, "/cmd_vel", self._cmd_vel_cb, 10) self.create_subscription(Twist, "/cmd_vel", self._cmd_vel_cb, 10)
self.create_subscription(Bool, "/estop", self._estop_cb, 10) self.create_subscription(Bool, "/estop", self._estop_cb, 10)
self.create_subscription(Bool, "/saltybot/arm", self._arm_cb, 10) self.add_on_set_parameters_callback(self._on_set_parameters)
# ── Timers ──────────────────────────────────────────────────────── # ── Timers ────────────────────────────────────────────────────────
self.create_timer(1.0 / _WATCHDOG_HZ, self._watchdog_cb) self.create_timer(1.0 / _WATCHDOG_HZ, self._watchdog_cb)
self.create_timer(_RECONNECT_INTERVAL_S, self._reconnect_cb) self.create_timer(_RECONNECT_INTERVAL_S, self._reconnect_cb)
# ── Open CAN ────────────────────────────────────────────────────── # ── Open CAN ──────────────────────────────────────────────────────
@ -120,17 +128,46 @@ class CanBridgeNode(Node):
self.get_logger().info( self.get_logger().info(
f"can_bridge_node ready — iface={self._iface} " f"can_bridge_node ready — iface={self._iface} "
f"left_vesc={self._left_vesc_id} right_vesc={self._right_vesc_id} " f"left_vesc={self._left_vesc_id} right_vesc={self._right_vesc_id} "
f"speed_scale={self._speed_scale} steer_scale={self._steer_scale}" f"balance={self._balance_id}"
) )
# -- PID parameter callback (Issue #693) --
def _on_set_parameters(self, params) -> SetParametersResult:
"""Send new PID gains over CAN when pid/* params change."""
for p in params:
if p.name == "pid/kp":
self._pid_kp = float(p.value)
elif p.name == "pid/ki":
self._pid_ki = float(p.value)
elif p.name == "pid/kd":
self._pid_kd = float(p.value)
else:
continue
try:
payload = encode_pid_set_cmd(self._pid_kp, self._pid_ki, self._pid_kd)
self._send_can(ORIN_CAN_ID_PID_SET, payload, "pid_set")
self.get_logger().info(
f"PID gains sent: Kp={self._pid_kp:.2f} "
f"Ki={self._pid_ki:.2f} Kd={self._pid_kd:.2f}"
)
except ValueError as exc:
return SetParametersResult(successful=False, reason=str(exc))
return SetParametersResult(successful=True)
# ── Connection management ────────────────────────────────────────────── # ── Connection management ──────────────────────────────────────────────
def _try_connect(self) -> None: def _try_connect(self) -> None:
"""Attempt to open the CAN interface; silently skip if already connected."""
with self._lock: with self._lock:
if self._connected: if self._connected:
return return
try: try:
self._bus = can.interface.Bus(channel=self._iface, bustype="socketcan") bus = can.interface.Bus(
channel=self._iface,
bustype="socketcan",
)
self._bus = bus
self._connected = True self._connected = True
self.get_logger().info(f"CAN bus connected: {self._iface}") self.get_logger().info(f"CAN bus connected: {self._iface}")
self._publish_status("connected") self._publish_status("connected")
@ -143,10 +180,12 @@ class CanBridgeNode(Node):
self._publish_status("disconnected") self._publish_status("disconnected")
def _reconnect_cb(self) -> None: def _reconnect_cb(self) -> None:
"""Periodic timer: try to reconnect when disconnected."""
if not self._connected: if not self._connected:
self._try_connect() self._try_connect()
def _handle_can_error(self, exc: Exception, context: str) -> None: def _handle_can_error(self, exc: Exception, context: str) -> None:
"""Mark bus as disconnected on any CAN error."""
self.get_logger().warning(f"CAN error in {context}: {exc}") self.get_logger().warning(f"CAN error in {context}: {exc}")
with self._lock: with self._lock:
if self._bus is not None: if self._bus is not None:
@ -161,8 +200,9 @@ class CanBridgeNode(Node):
# ── ROS callbacks ───────────────────────────────────────────────────── # ── ROS callbacks ─────────────────────────────────────────────────────
def _cmd_vel_cb(self, msg: Twist) -> None: def _cmd_vel_cb(self, msg: Twist) -> None:
"""Convert /cmd_vel Twist to ORIN_CMD_DRIVE over CAN.""" """Convert /cmd_vel Twist to VESC speed commands over CAN."""
self._last_cmd_time = time.monotonic() self._last_cmd_time = time.monotonic()
if not self._connected: if not self._connected:
return return
@ -179,40 +219,54 @@ class CanBridgeNode(Node):
right_mps = linear + angular right_mps = linear + angular
payload = encode_velocity_cmd(left_mps, right_mps) payload = encode_velocity_cmd(left_mps, right_mps)
self._send_can(MAMBA_CMD_VELOCITY, payload, "cmd_vel") self._send_can(BALANCE_CMD_VELOCITY, payload, "cmd_vel")
# Keep ESP32-S3 BALANCE in DRIVE mode while receiving commands # Keep ESP32-S3 BALANCE in DRIVE mode while receiving commands
self._send_can(MAMBA_CMD_MODE, encode_mode_cmd(MODE_DRIVE), "cmd_vel mode") self._send_can(BALANCE_CMD_MODE, encode_mode_cmd(MODE_DRIVE), "cmd_vel mode")
def _estop_cb(self, msg: Bool) -> None: def _estop_cb(self, msg: Bool) -> None:
"""Forward /estop to ESP32-S3 BALANCE over CAN.""" """Forward /estop to ESP32-S3 BALANCE over CAN."""
if not self._connected: if not self._connected:
return return
payload = encode_estop_cmd(msg.data)
self._send_can(BALANCE_CMD_ESTOP, payload, "estop")
if msg.data: if msg.data:
self._send_can( self._send_can(
MAMBA_CMD_MODE, encode_mode_cmd(MODE_ESTOP), "estop mode" BALANCE_CMD_MODE, encode_mode_cmd(MODE_ESTOP), "estop mode"
) )
self.get_logger().warning("E-stop asserted — sent ESTOP to ESP32-S3 BALANCE") self.get_logger().warning("E-stop asserted — sent ESTOP to ESP32-S3 BALANCE")
# ── Watchdog ────────────────────────────────────────────────────────── # ── Watchdog ──────────────────────────────────────────────────────────
def _watchdog_cb(self) -> None: def _watchdog_cb(self) -> None:
"""If /cmd_vel is silent for command_timeout_s, send zero DRIVE (acts as keepalive).""" """If no /cmd_vel arrives within the timeout, send zero velocity."""
if not self._connected: if not self._connected:
return return
if time.monotonic() - self._last_cmd_time > self._cmd_timeout: elapsed = time.monotonic() - self._last_cmd_time
self._send_can(ORIN_CMD_DRIVE, encode_drive_cmd(0, 0, MODE_IDLE), "watchdog") if elapsed > self._cmd_timeout:
self._send_can(
BALANCE_CMD_VELOCITY,
encode_velocity_cmd(0.0, 0.0),
"watchdog zero-vel",
)
self._send_can(
BALANCE_CMD_MODE, encode_mode_cmd(MODE_IDLE), "watchdog idle"
)
# ── CAN send helper ─────────────────────────────────────────────────── # ── CAN send helper ───────────────────────────────────────────────────
def _send_can(self, arb_id: int, data: bytes, context: str, def _send_can(self, arb_id: int, data: bytes, context: str) -> None:
extended: bool = False) -> None: """Send a standard CAN frame; handle errors gracefully."""
with self._lock: with self._lock:
if not self._connected or self._bus is None: if not self._connected or self._bus is None:
return return
bus = self._bus bus = self._bus
msg = can.Message(arbitration_id=arb_id, data=data,
is_extended_id=extended) msg = can.Message(
arbitration_id=arb_id,
data=data,
is_extended_id=False,
)
try: try:
bus.send(msg, timeout=0.05) bus.send(msg, timeout=0.05)
except can.CanError as exc: except can.CanError as exc:
@ -221,41 +275,55 @@ class CanBridgeNode(Node):
# ── Background CAN reader ───────────────────────────────────────────── # ── Background CAN reader ─────────────────────────────────────────────
def _reader_loop(self) -> None: def _reader_loop(self) -> None:
"""
Blocking CAN read loop executed in a daemon thread.
Dispatches incoming frames to the appropriate handler.
"""
while rclpy.ok(): while rclpy.ok():
with self._lock: with self._lock:
connected, bus = self._connected, self._bus connected = self._connected
bus = self._bus
if not connected or bus is None: if not connected or bus is None:
time.sleep(0.1) time.sleep(0.1)
continue continue
try: try:
frame = bus.recv(timeout=0.5) frame = bus.recv(timeout=0.5)
except can.CanError as exc: except can.CanError as exc:
self._handle_can_error(exc, "reader_loop recv") self._handle_can_error(exc, "reader_loop recv")
continue continue
if frame is None: if frame is None:
# Timeout — no frame within 0.5 s, loop again
continue continue
self._dispatch_frame(frame) self._dispatch_frame(frame)
def _dispatch_frame(self, frame: can.Message) -> None: def _dispatch_frame(self, frame: can.Message) -> None:
"""Route an incoming CAN frame to the correct publisher."""
arb_id = frame.arbitration_id arb_id = frame.arbitration_id
data = bytes(frame.data) data = bytes(frame.data)
vesc_l = (VESC_STATUS_1 << 8) | self._left_vesc_id
vesc_r = (VESC_STATUS_1 << 8) | self._right_vesc_id
try: try:
if arb_id == ESP32_TELEM_ATTITUDE: if arb_id == BALANCE_TELEM_IMU:
self._handle_attitude(data) self._handle_imu(data, frame.timestamp)
elif arb_id == ESP32_TELEM_BATTERY:
self._handle_battery(data) elif arb_id == BALANCE_TELEM_BATTERY:
elif arb_id == vesc_l: self._handle_battery(data, frame.timestamp)
t = decode_vesc_status1(self._left_vesc_id, data)
m = Float32MultiArray() elif arb_id == VESC_TELEM_STATE + self._left_vesc_id:
m.data = [t.erpm, t.duty, 0.0, t.current] self._handle_vesc_state(data, frame.timestamp, side="left")
self._pub_vesc_left.publish(m)
elif arb_id == vesc_r: elif arb_id == VESC_TELEM_STATE + self._right_vesc_id:
t = decode_vesc_status1(self._right_vesc_id, data) self._handle_vesc_state(data, frame.timestamp, side="right")
m = Float32MultiArray()
m.data = [t.erpm, t.duty, 0.0, t.current] elif arb_id == ORIN_CAN_ID_FC_PID_ACK:
self._pub_vesc_right.publish(m) gains = decode_pid_ack(data)
self.get_logger().debug(
f"FC PID ACK: Kp={gains.kp:.2f} Ki={gains.ki:.2f} Kd={gains.kd:.2f}"
)
except Exception as exc: except Exception as exc:
self.get_logger().warning( self.get_logger().warning(
f"Error parsing CAN frame 0x{arb_id:03X}: {exc}" f"Error parsing CAN frame 0x{arb_id:03X}: {exc}"
@ -263,36 +331,52 @@ class CanBridgeNode(Node):
# ── Frame handlers ──────────────────────────────────────────────────── # ── Frame handlers ────────────────────────────────────────────────────
_STATE_LABEL = {0: "IDLE", 1: "RUNNING", 2: "FAULT"} def _handle_imu(self, data: bytes, timestamp: float) -> None:
telem = decode_imu_telem(data)
def _handle_attitude(self, data: bytes) -> None: msg = Imu()
"""ATTITUDE (0x400): pitch, speed, yaw_rate, state, flags → /saltybot/attitude.""" msg.header.stamp = self.get_clock().now().to_msg()
t = decode_attitude(data) msg.header.frame_id = "imu_link"
now = self.get_clock().now().to_msg()
payload = { msg.linear_acceleration.x = telem.accel_x
"pitch_deg": round(t.pitch_deg, 2), msg.linear_acceleration.y = telem.accel_y
"speed_mps": round(t.speed, 3), msg.linear_acceleration.z = telem.accel_z
"yaw_rate": round(t.yaw_rate, 3),
"state": t.state, msg.angular_velocity.x = telem.gyro_x
"state_label": self._STATE_LABEL.get(t.state, f"UNKNOWN({t.state})"), msg.angular_velocity.y = telem.gyro_y
"flags": t.flags, msg.angular_velocity.z = telem.gyro_z
"ts": f"{now.sec}.{now.nanosec:09d}",
} # Covariance unknown; mark as -1 per REP-145
msg = String() msg.orientation_covariance[0] = -1.0
msg.data = json.dumps(payload)
self._pub_attitude.publish(msg) self._pub_imu.publish(msg)
self._pub_balance.publish(msg) # keep /saltybot/balance_state alive
def _handle_battery(self, data: bytes, timestamp: float) -> None:
telem = decode_battery_telem(data)
def _handle_battery(self, data: bytes) -> None:
"""BATTERY (0x401): vbat_mv, fault_code, rssi → /can/battery."""
t = decode_battery(data)
msg = BatteryState() msg = BatteryState()
msg.header.stamp = self.get_clock().now().to_msg() msg.header.stamp = self.get_clock().now().to_msg()
msg.voltage = t.vbat_mv / 1000.0 msg.voltage = telem.voltage
msg.current = telem.current
msg.present = True msg.present = True
msg.power_supply_status = BatteryState.POWER_SUPPLY_STATUS_DISCHARGING msg.power_supply_status = BatteryState.POWER_SUPPLY_STATUS_DISCHARGING
self._pub_battery.publish(msg) self._pub_battery.publish(msg)
def _handle_vesc_state(
self, data: bytes, timestamp: float, side: str
) -> None:
telem = decode_vesc_state(data)
msg = Float32MultiArray()
# Layout: [erpm, duty, voltage, current]
msg.data = [telem.erpm, telem.duty, telem.voltage, telem.current]
if side == "left":
self._pub_vesc_left.publish(msg)
else:
self._pub_vesc_right.publish(msg)
# ── Status helper ───────────────────────────────────────────────────── # ── Status helper ─────────────────────────────────────────────────────
def _publish_status(self, status: str) -> None: def _publish_status(self, status: str) -> None:
@ -303,10 +387,17 @@ class CanBridgeNode(Node):
# ── Shutdown ────────────────────────────────────────────────────────── # ── Shutdown ──────────────────────────────────────────────────────────
def destroy_node(self) -> None: def destroy_node(self) -> None:
"""Send zero velocity and shut down the CAN bus cleanly."""
if self._connected and self._bus is not None: if self._connected and self._bus is not None:
try: try:
self._send_can(ORIN_CMD_DRIVE, encode_drive_cmd(0, 0, MODE_IDLE), "shutdown") self._send_can(
self._send_can(ORIN_CMD_ARM, encode_arm_cmd(False), "shutdown") BALANCE_CMD_VELOCITY,
encode_velocity_cmd(0.0, 0.0),
"shutdown",
)
self._send_can(
BALANCE_CMD_MODE, encode_mode_cmd(MODE_IDLE), "shutdown"
)
except Exception: except Exception:
pass pass
try: try:
@ -316,6 +407,8 @@ class CanBridgeNode(Node):
super().destroy_node() super().destroy_node()
# ---------------------------------------------------------------------------
def main(args=None) -> None: def main(args=None) -> None:
rclpy.init(args=args) rclpy.init(args=args)
node = CanBridgeNode() node = CanBridgeNode()

View File

@ -15,11 +15,7 @@ setup(
zip_safe=True, zip_safe=True,
maintainer="sl-controls", maintainer="sl-controls",
maintainer_email="sl-controls@saltylab.local", maintainer_email="sl-controls@saltylab.local",
<<<<<<< HEAD
description="CAN bus bridge for ESP32 IO motor controller and VESC telemetry",
=======
description="CAN bus bridge for ESP32-S3 BALANCE controller and VESC telemetry", description="CAN bus bridge for ESP32-S3 BALANCE controller and VESC telemetry",
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
license="MIT", license="MIT",
tests_require=["pytest"], tests_require=["pytest"],
entry_points={ entry_points={

View File

@ -12,11 +12,11 @@ import struct
import unittest import unittest
from saltybot_can_bridge.balance_protocol import ( from saltybot_can_bridge.balance_protocol import (
MAMBA_CMD_ESTOP, BALANCE_CMD_ESTOP,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_TELEM_BATTERY, BALANCE_TELEM_BATTERY,
MAMBA_TELEM_IMU, BALANCE_TELEM_IMU,
VESC_TELEM_STATE, VESC_TELEM_STATE,
MODE_DRIVE, MODE_DRIVE,
MODE_ESTOP, MODE_ESTOP,
@ -37,13 +37,13 @@ class TestMessageIDs(unittest.TestCase):
"""Verify the CAN message ID constants are correct.""" """Verify the CAN message ID constants are correct."""
def test_command_ids(self): def test_command_ids(self):
self.assertEqual(MAMBA_CMD_VELOCITY, 0x100) self.assertEqual(BALANCE_CMD_VELOCITY, 0x100)
self.assertEqual(MAMBA_CMD_MODE, 0x101) self.assertEqual(BALANCE_CMD_MODE, 0x101)
self.assertEqual(MAMBA_CMD_ESTOP, 0x102) self.assertEqual(BALANCE_CMD_ESTOP, 0x102)
def test_telemetry_ids(self): def test_telemetry_ids(self):
self.assertEqual(MAMBA_TELEM_IMU, 0x200) self.assertEqual(BALANCE_TELEM_IMU, 0x200)
self.assertEqual(MAMBA_TELEM_BATTERY, 0x201) self.assertEqual(BALANCE_TELEM_BATTERY, 0x201)
self.assertEqual(VESC_TELEM_STATE, 0x300) self.assertEqual(VESC_TELEM_STATE, 0x300)

View File

@ -4,31 +4,28 @@ protocol_defs.py — CAN message ID constants and frame builders/parsers for the
OrinESP32-S3 BALANCEVESC integration test suite. OrinESP32-S3 BALANCEVESC integration test suite.
All IDs and payload formats are derived from: All IDs and payload formats are derived from:
include/orin_can.h OrinFC (ESP32-S3 BALANCE) protocol include/orin_can.h OrinESP32-S3 BALANCE protocol
include/vesc_can.h VESC CAN protocol include/vesc_can.h VESC CAN protocol
saltybot_can_bridge/balance_protocol.py existing bridge constants saltybot_can_bridge/balance_protocol.py existing bridge constants
CAN IDs used in tests CAN IDs used in tests
--------------------- ---------------------
Orin FC (ESP32-S3 BALANCE) commands (standard 11-bit, matching orin_can.h): Orin ESP32-S3 BALANCE commands (standard 11-bit, matching orin_can.h):
ORIN_CMD_HEARTBEAT 0x300 ORIN_CMD_HEARTBEAT 0x300
ORIN_CMD_DRIVE 0x301 int16 speed (1000..+1000), int16 steer (1000..+1000) ORIN_CMD_DRIVE 0x301 int16 speed (1000..+1000), int16 steer (1000..+1000)
ORIN_CMD_MODE 0x302 uint8 mode byte ORIN_CMD_MODE 0x302 uint8 mode byte
ORIN_CMD_ESTOP 0x303 uint8 action (1=ESTOP, 0=CLEAR) ORIN_CMD_ESTOP 0x303 uint8 action (1=ESTOP, 0=CLEAR)
FC (ESP32-S3 BALANCE) Orin telemetry (standard 11-bit, matching orin_can.h): ESP32-S3 BALANCE Orin telemetry (standard 11-bit, matching orin_can.h):
FC_STATUS 0x400 8 bytes (see orin_can_fc_status_t) FC_STATUS 0x400 8 bytes (see orin_can_fc_status_t)
FC_VESC 0x401 8 bytes (see orin_can_fc_vesc_t) FC_VESC 0x401 8 bytes (see orin_can_fc_vesc_t)
FC_IMU 0x402 8 bytes FC_IMU 0x402 8 bytes
FC_BARO 0x403 8 bytes FC_BARO 0x403 8 bytes
Mamba VESC internal commands (matching balance_protocol.py):
=======
ESP32-S3 BALANCE VESC internal commands (matching balance_protocol.py): ESP32-S3 BALANCE VESC internal commands (matching balance_protocol.py):
>>>>>>> 9aed963 (fix: scrub remaining Mamba references in can_bridge and e2e test protocol files) BALANCE_CMD_VELOCITY 0x100 8 bytes left_mps (f32) | right_mps (f32) big-endian
MAMBA_CMD_VELOCITY 0x100 8 bytes left_mps (f32) | right_mps (f32) big-endian BALANCE_CMD_MODE 0x101 1 byte mode (0=idle,1=drive,2=estop)
MAMBA_CMD_MODE 0x101 1 byte mode (0=idle,1=drive,2=estop) BALANCE_CMD_ESTOP 0x102 1 byte 0x01=stop
MAMBA_CMD_ESTOP 0x102 1 byte 0x01=stop
VESC STATUS (extended 29-bit, matching vesc_can.h): VESC STATUS (extended 29-bit, matching vesc_can.h):
arb_id = (VESC_PKT_STATUS << 8) | vesc_node_id = (9 << 8) | node_id arb_id = (VESC_PKT_STATUS << 8) | vesc_node_id = (9 << 8) | node_id
@ -39,7 +36,7 @@ import struct
from typing import Tuple from typing import Tuple
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Orin → FC (ESP32-S3 BALANCE) command IDs (from orin_can.h) # Orin → ESP32-S3 BALANCE command IDs (from orin_can.h)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ORIN_CMD_HEARTBEAT: int = 0x300 ORIN_CMD_HEARTBEAT: int = 0x300
@ -48,7 +45,7 @@ ORIN_CMD_MODE: int = 0x302
ORIN_CMD_ESTOP: int = 0x303 ORIN_CMD_ESTOP: int = 0x303
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FC (ESP32-S3 BALANCE) → Orin telemetry IDs (from orin_can.h) # ESP32-S3 BALANCE → Orin telemetry IDs (from orin_can.h)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FC_STATUS: int = 0x400 FC_STATUS: int = 0x400
@ -57,18 +54,15 @@ FC_IMU: int = 0x402
FC_BARO: int = 0x403 FC_BARO: int = 0x403
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mamba → VESC internal command IDs (from balance_protocol.py)
=======
# ESP32-S3 BALANCE → VESC internal command IDs (from balance_protocol.py) # ESP32-S3 BALANCE → VESC internal command IDs (from balance_protocol.py)
>>>>>>> 9aed963 (fix: scrub remaining Mamba references in can_bridge and e2e test protocol files)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MAMBA_CMD_VELOCITY: int = 0x100 BALANCE_CMD_VELOCITY: int = 0x100
MAMBA_CMD_MODE: int = 0x101 BALANCE_CMD_MODE: int = 0x101
MAMBA_CMD_ESTOP: int = 0x102 BALANCE_CMD_ESTOP: int = 0x102
MAMBA_TELEM_IMU: int = 0x200 BALANCE_TELEM_IMU: int = 0x200
MAMBA_TELEM_BATTERY: int = 0x201 BALANCE_TELEM_BATTERY: int = 0x201
VESC_TELEM_STATE: int = 0x300 VESC_TELEM_STATE: int = 0x300
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -142,15 +136,12 @@ def build_estop_cmd(action: int = 1) -> bytes:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Frame builders — Mamba velocity commands (balance_protocol.py encoding)
=======
# Frame builders — ESP32-S3 BALANCE velocity commands (balance_protocol.py encoding) # Frame builders — ESP32-S3 BALANCE velocity commands (balance_protocol.py encoding)
>>>>>>> 9aed963 (fix: scrub remaining Mamba references in can_bridge and e2e test protocol files)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_velocity_cmd(left_mps: float, right_mps: float) -> bytes: def build_velocity_cmd(left_mps: float, right_mps: float) -> bytes:
""" """
Build a MAMBA_CMD_VELOCITY payload (8 bytes, 2 × float32 big-endian). Build a BALANCE_CMD_VELOCITY payload (8 bytes, 2 × float32 big-endian).
Matches encode_velocity_cmd() in balance_protocol.py. Matches encode_velocity_cmd() in balance_protocol.py.
""" """
@ -312,12 +303,12 @@ def parse_vesc_status(data: bytes):
def parse_velocity_cmd(data: bytes) -> Tuple[float, float]: def parse_velocity_cmd(data: bytes) -> Tuple[float, float]:
""" """
Parse a MAMBA_CMD_VELOCITY payload (8 bytes, 2 × float32 big-endian). Parse a BALANCE_CMD_VELOCITY payload (8 bytes, 2 × float32 big-endian).
Returns Returns
------- -------
(left_mps, right_mps) (left_mps, right_mps)
""" """
if len(data) < 8: if len(data) < 8:
raise ValueError(f"MAMBA_CMD_VELOCITY needs 8 bytes, got {len(data)}") raise ValueError(f"BALANCE_CMD_VELOCITY needs 8 bytes, got {len(data)}")
return struct.unpack(">ff", data[:8]) return struct.unpack(">ff", data[:8])

View File

@ -14,8 +14,8 @@ import struct
import pytest import pytest
from saltybot_can_e2e_test.protocol_defs import ( from saltybot_can_e2e_test.protocol_defs import (
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
FC_VESC, FC_VESC,
MODE_DRIVE, MODE_DRIVE,
MODE_IDLE, MODE_IDLE,
@ -50,8 +50,8 @@ def _send_drive(bus, left_mps: float, right_mps: float) -> None:
self.data = bytearray(data) self.data = bytearray(data)
self.is_extended_id = False self.is_extended_id = False
bus.send(_Msg(MAMBA_CMD_VELOCITY, payload)) bus.send(_Msg(BALANCE_CMD_VELOCITY, payload))
bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_DRIVE))) bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_DRIVE)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -62,11 +62,11 @@ class TestDriveForward:
def test_drive_forward_velocity_frame_sent(self, mock_can_bus): def test_drive_forward_velocity_frame_sent(self, mock_can_bus):
""" """
Inject DRIVE cmd (1.0 m/s, 1.0 m/s) verify ESP32-S3 BALANCE receives Inject DRIVE cmd (1.0 m/s, 1.0 m/s) verify ESP32-S3 BALANCE receives
a MAMBA_CMD_VELOCITY frame with correct payload. a BALANCE_CMD_VELOCITY frame with correct payload.
""" """
_send_drive(mock_can_bus, 1.0, 1.0) _send_drive(mock_can_bus, 1.0, 1.0)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1, "Expected exactly one velocity command frame" assert len(vel_frames) == 1, "Expected exactly one velocity command frame"
left, right = parse_velocity_cmd(bytes(vel_frames[0].data)) left, right = parse_velocity_cmd(bytes(vel_frames[0].data))
@ -77,7 +77,7 @@ class TestDriveForward:
"""After a drive command, a MODE=drive frame must accompany it.""" """After a drive command, a MODE=drive frame must accompany it."""
_send_drive(mock_can_bus, 1.0, 1.0) _send_drive(mock_can_bus, 1.0, 1.0)
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert len(mode_frames) >= 1, "Expected at least one MODE frame" assert len(mode_frames) >= 1, "Expected at least one MODE frame"
assert bytes(mode_frames[0].data) == bytes([MODE_DRIVE]) assert bytes(mode_frames[0].data) == bytes([MODE_DRIVE])
@ -109,7 +109,7 @@ class TestDriveTurn:
""" """
_send_drive(mock_can_bus, 0.5, -0.5) _send_drive(mock_can_bus, 0.5, -0.5)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1 assert len(vel_frames) == 1
left, right = parse_velocity_cmd(bytes(vel_frames[0].data)) left, right = parse_velocity_cmd(bytes(vel_frames[0].data))
@ -142,7 +142,7 @@ class TestDriveZero:
_send_drive(mock_can_bus, 0.0, 0.0) _send_drive(mock_can_bus, 0.0, 0.0)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1 assert len(vel_frames) == 1
left, right = parse_velocity_cmd(bytes(vel_frames[0].data)) left, right = parse_velocity_cmd(bytes(vel_frames[0].data))
assert abs(left) < 1e-5, "Left motor not stopped" assert abs(left) < 1e-5, "Left motor not stopped"
@ -156,7 +156,7 @@ class TestDriveCmdTimeout:
zero velocity is sent. We test the encoding directly (without timers). zero velocity is sent. We test the encoding directly (without timers).
""" """
# The watchdog in CanBridgeNode calls encode_velocity_cmd(0.0, 0.0) and # The watchdog in CanBridgeNode calls encode_velocity_cmd(0.0, 0.0) and
# sends it on MAMBA_CMD_VELOCITY. Replicate that here. # sends it on BALANCE_CMD_VELOCITY. Replicate that here.
zero_payload = encode_velocity_cmd(0.0, 0.0) zero_payload = encode_velocity_cmd(0.0, 0.0)
class _Msg: class _Msg:
@ -165,16 +165,16 @@ class TestDriveCmdTimeout:
self.data = bytearray(data) self.data = bytearray(data)
self.is_extended_id = False self.is_extended_id = False
mock_can_bus.send(_Msg(MAMBA_CMD_VELOCITY, zero_payload)) mock_can_bus.send(_Msg(BALANCE_CMD_VELOCITY, zero_payload))
mock_can_bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_IDLE))) mock_can_bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_IDLE)))
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1 assert len(vel_frames) == 1
left, right = parse_velocity_cmd(bytes(vel_frames[0].data)) left, right = parse_velocity_cmd(bytes(vel_frames[0].data))
assert abs(left) < 1e-5 assert abs(left) < 1e-5
assert abs(right) < 1e-5 assert abs(right) < 1e-5
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert len(mode_frames) == 1 assert len(mode_frames) == 1
assert bytes(mode_frames[0].data) == bytes([MODE_IDLE]) assert bytes(mode_frames[0].data) == bytes([MODE_IDLE])

View File

@ -17,9 +17,9 @@ import pytest
from saltybot_can_e2e_test.can_mock import MockCANBus from saltybot_can_e2e_test.can_mock import MockCANBus
from saltybot_can_e2e_test.protocol_defs import ( from saltybot_can_e2e_test.protocol_defs import (
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
MAMBA_CMD_ESTOP, BALANCE_CMD_ESTOP,
ORIN_CMD_ESTOP, ORIN_CMD_ESTOP,
FC_STATUS, FC_STATUS,
MODE_IDLE, MODE_IDLE,
@ -68,16 +68,16 @@ class EstopStateMachine:
"""Send ESTOP and transition to estop mode.""" """Send ESTOP and transition to estop mode."""
self._estop_active = True self._estop_active = True
self._mode = MODE_ESTOP self._mode = MODE_ESTOP
self._bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0))) self._bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0)))
self._bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_ESTOP))) self._bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_ESTOP)))
self._bus.send(_Msg(MAMBA_CMD_ESTOP, encode_estop_cmd(True))) self._bus.send(_Msg(BALANCE_CMD_ESTOP, encode_estop_cmd(True)))
def clear_estop(self) -> None: def clear_estop(self) -> None:
"""Clear ESTOP and return to IDLE mode.""" """Clear ESTOP and return to IDLE mode."""
self._estop_active = False self._estop_active = False
self._mode = MODE_IDLE self._mode = MODE_IDLE
self._bus.send(_Msg(MAMBA_CMD_ESTOP, encode_estop_cmd(False))) self._bus.send(_Msg(BALANCE_CMD_ESTOP, encode_estop_cmd(False)))
self._bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_IDLE))) self._bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_IDLE)))
def send_drive(self, left_mps: float, right_mps: float) -> None: def send_drive(self, left_mps: float, right_mps: float) -> None:
"""Send velocity command only if ESTOP is not active.""" """Send velocity command only if ESTOP is not active."""
@ -85,8 +85,8 @@ class EstopStateMachine:
# Bridge silently drops commands while estopped # Bridge silently drops commands while estopped
return return
self._mode = MODE_DRIVE self._mode = MODE_DRIVE
self._bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(left_mps, right_mps))) self._bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(left_mps, right_mps)))
self._bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_DRIVE))) self._bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_DRIVE)))
@property @property
def estop_active(self) -> bool: def estop_active(self) -> bool:
@ -105,7 +105,7 @@ class TestEstopHaltsMotors:
sm = EstopStateMachine(mock_can_bus) sm = EstopStateMachine(mock_can_bus)
sm.assert_estop() sm.assert_estop()
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) >= 1, "No velocity frame after ESTOP" assert len(vel_frames) >= 1, "No velocity frame after ESTOP"
l, r = parse_velocity_cmd(bytes(vel_frames[-1].data)) l, r = parse_velocity_cmd(bytes(vel_frames[-1].data))
assert abs(l) < 1e-5, f"Left motor {l} not zero after ESTOP" assert abs(l) < 1e-5, f"Left motor {l} not zero after ESTOP"
@ -116,17 +116,17 @@ class TestEstopHaltsMotors:
sm = EstopStateMachine(mock_can_bus) sm = EstopStateMachine(mock_can_bus)
sm.assert_estop() sm.assert_estop()
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert any( assert any(
bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames
), "MODE=ESTOP not found in sent frames" ), "MODE=ESTOP not found in sent frames"
def test_estop_flag_byte_is_0x01(self, mock_can_bus): def test_estop_flag_byte_is_0x01(self, mock_can_bus):
"""MAMBA_CMD_ESTOP payload must be 0x01 when asserting e-stop.""" """BALANCE_CMD_ESTOP payload must be 0x01 when asserting e-stop."""
sm = EstopStateMachine(mock_can_bus) sm = EstopStateMachine(mock_can_bus)
sm.assert_estop() sm.assert_estop()
estop_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_ESTOP) estop_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_ESTOP)
assert len(estop_frames) >= 1 assert len(estop_frames) >= 1
assert bytes(estop_frames[-1].data) == b"\x01", \ assert bytes(estop_frames[-1].data) == b"\x01", \
f"ESTOP payload {estop_frames[-1].data!r} != 0x01" f"ESTOP payload {estop_frames[-1].data!r} != 0x01"
@ -143,7 +143,7 @@ class TestEstopPersists:
sm.send_drive(1.0, 1.0) # should be suppressed sm.send_drive(1.0, 1.0) # should be suppressed
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 0, \ assert len(vel_frames) == 0, \
"Velocity command was forwarded while ESTOP is active" "Velocity command was forwarded while ESTOP is active"
@ -158,7 +158,7 @@ class TestEstopPersists:
sm.send_drive(0.5, 0.5) sm.send_drive(0.5, 0.5)
# No mode frames should have been emitted (drive was suppressed) # No mode frames should have been emitted (drive was suppressed)
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert all( assert all(
bytes(f.data) != bytes([MODE_DRIVE]) for f in mode_frames bytes(f.data) != bytes([MODE_DRIVE]) for f in mode_frames
), "MODE=DRIVE was set despite active ESTOP" ), "MODE=DRIVE was set despite active ESTOP"
@ -174,19 +174,19 @@ class TestEstopClear:
sm.send_drive(0.8, 0.8) sm.send_drive(0.8, 0.8)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1, "Velocity command not sent after ESTOP clear" assert len(vel_frames) == 1, "Velocity command not sent after ESTOP clear"
l, r = parse_velocity_cmd(bytes(vel_frames[0].data)) l, r = parse_velocity_cmd(bytes(vel_frames[0].data))
assert abs(l - 0.8) < 1e-4 assert abs(l - 0.8) < 1e-4
assert abs(r - 0.8) < 1e-4 assert abs(r - 0.8) < 1e-4
def test_estop_clear_flag_byte_is_0x00(self, mock_can_bus): def test_estop_clear_flag_byte_is_0x00(self, mock_can_bus):
"""MAMBA_CMD_ESTOP payload must be 0x00 when clearing e-stop.""" """BALANCE_CMD_ESTOP payload must be 0x00 when clearing e-stop."""
sm = EstopStateMachine(mock_can_bus) sm = EstopStateMachine(mock_can_bus)
sm.assert_estop() sm.assert_estop()
sm.clear_estop() sm.clear_estop()
estop_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_ESTOP) estop_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_ESTOP)
assert len(estop_frames) >= 2 assert len(estop_frames) >= 2
# Last ESTOP frame should be the clear # Last ESTOP frame should be the clear
assert bytes(estop_frames[-1].data) == b"\x00", \ assert bytes(estop_frames[-1].data) == b"\x00", \
@ -198,7 +198,7 @@ class TestEstopClear:
sm.assert_estop() sm.assert_estop()
sm.clear_estop() sm.clear_estop()
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
last_mode = bytes(mode_frames[-1].data) last_mode = bytes(mode_frames[-1].data)
assert last_mode == bytes([MODE_IDLE]), \ assert last_mode == bytes([MODE_IDLE]), \
f"Mode after ESTOP clear is {last_mode!r}, expected MODE_IDLE" f"Mode after ESTOP clear is {last_mode!r}, expected MODE_IDLE"

View File

@ -21,9 +21,9 @@ from saltybot_can_e2e_test.protocol_defs import (
ORIN_CMD_HEARTBEAT, ORIN_CMD_HEARTBEAT,
ORIN_CMD_ESTOP, ORIN_CMD_ESTOP,
ORIN_CMD_MODE, ORIN_CMD_MODE,
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
MAMBA_CMD_ESTOP, BALANCE_CMD_ESTOP,
MODE_IDLE, MODE_IDLE,
MODE_DRIVE, MODE_DRIVE,
MODE_ESTOP, MODE_ESTOP,
@ -100,9 +100,9 @@ def _simulate_estop_on_timeout(bus: MockCANBus) -> None:
self.data = bytearray(data) self.data = bytearray(data)
self.is_extended_id = False self.is_extended_id = False
bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0))) bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0)))
bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_ESTOP))) bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_ESTOP)))
bus.send(_Msg(MAMBA_CMD_ESTOP, encode_estop_cmd(True))) bus.send(_Msg(BALANCE_CMD_ESTOP, encode_estop_cmd(True)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -121,25 +121,25 @@ class TestHeartbeatLoss:
# Simulate bridge detecting timeout and escalating # Simulate bridge detecting timeout and escalating
_simulate_estop_on_timeout(mock_can_bus) _simulate_estop_on_timeout(mock_can_bus)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) >= 1, "Zero velocity not sent after timeout" assert len(vel_frames) >= 1, "Zero velocity not sent after timeout"
l, r = parse_velocity_cmd(bytes(vel_frames[-1].data)) l, r = parse_velocity_cmd(bytes(vel_frames[-1].data))
assert abs(l) < 1e-5, "Left not zero on timeout" assert abs(l) < 1e-5, "Left not zero on timeout"
assert abs(r) < 1e-5, "Right not zero on timeout" assert abs(r) < 1e-5, "Right not zero on timeout"
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert any( assert any(
bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames
), "ESTOP mode not asserted on heartbeat timeout" ), "ESTOP mode not asserted on heartbeat timeout"
estop_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_ESTOP) estop_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_ESTOP)
assert len(estop_frames) >= 1, "ESTOP command not sent" assert len(estop_frames) >= 1, "ESTOP command not sent"
assert bytes(estop_frames[0].data) == b"\x01" assert bytes(estop_frames[0].data) == b"\x01"
def test_heartbeat_loss_zero_velocity(self, mock_can_bus): def test_heartbeat_loss_zero_velocity(self, mock_can_bus):
"""Zero velocity frame must appear among sent frames after timeout.""" """Zero velocity frame must appear among sent frames after timeout."""
_simulate_estop_on_timeout(mock_can_bus) _simulate_estop_on_timeout(mock_can_bus)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) >= 1 assert len(vel_frames) >= 1
for f in vel_frames: for f in vel_frames:
l, r = parse_velocity_cmd(bytes(f.data)) l, r = parse_velocity_cmd(bytes(f.data))
@ -165,20 +165,20 @@ class TestHeartbeatRecovery:
mock_can_bus.reset() mock_can_bus.reset()
# Phase 2: recovery — clear estop, restore drive mode # Phase 2: recovery — clear estop, restore drive mode
mock_can_bus.send(_Msg(MAMBA_CMD_ESTOP, encode_estop_cmd(False))) mock_can_bus.send(_Msg(BALANCE_CMD_ESTOP, encode_estop_cmd(False)))
mock_can_bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(MODE_DRIVE))) mock_can_bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(MODE_DRIVE)))
mock_can_bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(0.5, 0.5))) mock_can_bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(0.5, 0.5)))
estop_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_ESTOP) estop_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_ESTOP)
assert any(bytes(f.data) == b"\x00" for f in estop_frames), \ assert any(bytes(f.data) == b"\x00" for f in estop_frames), \
"ESTOP clear not sent on recovery" "ESTOP clear not sent on recovery"
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert any( assert any(
bytes(f.data) == bytes([MODE_DRIVE]) for f in mode_frames bytes(f.data) == bytes([MODE_DRIVE]) for f in mode_frames
), "DRIVE mode not restored after recovery" ), "DRIVE mode not restored after recovery"
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) >= 1 assert len(vel_frames) >= 1
l, r = parse_velocity_cmd(bytes(vel_frames[-1].data)) l, r = parse_velocity_cmd(bytes(vel_frames[-1].data))
assert abs(l - 0.5) < 1e-4 assert abs(l - 0.5) < 1e-4

View File

@ -17,9 +17,9 @@ import pytest
from saltybot_can_e2e_test.can_mock import MockCANBus from saltybot_can_e2e_test.can_mock import MockCANBus
from saltybot_can_e2e_test.protocol_defs import ( from saltybot_can_e2e_test.protocol_defs import (
MAMBA_CMD_VELOCITY, BALANCE_CMD_VELOCITY,
MAMBA_CMD_MODE, BALANCE_CMD_MODE,
MAMBA_CMD_ESTOP, BALANCE_CMD_ESTOP,
MODE_IDLE, MODE_IDLE,
MODE_DRIVE, MODE_DRIVE,
MODE_ESTOP, MODE_ESTOP,
@ -64,12 +64,12 @@ class ModeStateMachine:
prev_mode = self._mode prev_mode = self._mode
self._mode = mode self._mode = mode
self._bus.send(_Msg(MAMBA_CMD_MODE, encode_mode_cmd(mode))) self._bus.send(_Msg(BALANCE_CMD_MODE, encode_mode_cmd(mode)))
# Side-effects of entering ESTOP from DRIVE # Side-effects of entering ESTOP from DRIVE
if mode == MODE_ESTOP and prev_mode == MODE_DRIVE: if mode == MODE_ESTOP and prev_mode == MODE_DRIVE:
self._bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0))) self._bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0)))
self._bus.send(_Msg(MAMBA_CMD_ESTOP, encode_estop_cmd(True))) self._bus.send(_Msg(BALANCE_CMD_ESTOP, encode_estop_cmd(True)))
return True return True
@ -79,7 +79,7 @@ class ModeStateMachine:
""" """
if self._mode != MODE_DRIVE: if self._mode != MODE_DRIVE:
return False return False
self._bus.send(_Msg(MAMBA_CMD_VELOCITY, encode_velocity_cmd(left_mps, right_mps))) self._bus.send(_Msg(BALANCE_CMD_VELOCITY, encode_velocity_cmd(left_mps, right_mps)))
return True return True
@property @property
@ -97,7 +97,7 @@ class TestIdleToDrive:
sm = ModeStateMachine(mock_can_bus) sm = ModeStateMachine(mock_can_bus)
sm.set_mode(MODE_DRIVE) sm.set_mode(MODE_DRIVE)
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert len(mode_frames) == 1 assert len(mode_frames) == 1
assert bytes(mode_frames[0].data) == bytes([MODE_DRIVE]) assert bytes(mode_frames[0].data) == bytes([MODE_DRIVE])
@ -108,7 +108,7 @@ class TestIdleToDrive:
forwarded = sm.send_drive(1.0, 1.0) forwarded = sm.send_drive(1.0, 1.0)
assert forwarded is False, "Drive cmd should be blocked in IDLE mode" assert forwarded is False, "Drive cmd should be blocked in IDLE mode"
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 0 assert len(vel_frames) == 0
def test_drive_mode_allows_commands(self, mock_can_bus): def test_drive_mode_allows_commands(self, mock_can_bus):
@ -120,7 +120,7 @@ class TestIdleToDrive:
forwarded = sm.send_drive(0.5, 0.5) forwarded = sm.send_drive(0.5, 0.5)
assert forwarded is True assert forwarded is True
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 1 assert len(vel_frames) == 1
l, r = parse_velocity_cmd(bytes(vel_frames[0].data)) l, r = parse_velocity_cmd(bytes(vel_frames[0].data))
assert abs(l - 0.5) < 1e-4 assert abs(l - 0.5) < 1e-4
@ -137,7 +137,7 @@ class TestDriveToEstop:
sm.set_mode(MODE_ESTOP) sm.set_mode(MODE_ESTOP)
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) >= 1, "No velocity frame on DRIVE→ESTOP transition" assert len(vel_frames) >= 1, "No velocity frame on DRIVE→ESTOP transition"
l, r = parse_velocity_cmd(bytes(vel_frames[-1].data)) l, r = parse_velocity_cmd(bytes(vel_frames[-1].data))
assert abs(l) < 1e-5, f"Left motor {l} not zero after ESTOP" assert abs(l) < 1e-5, f"Left motor {l} not zero after ESTOP"
@ -149,7 +149,7 @@ class TestDriveToEstop:
sm.set_mode(MODE_DRIVE) sm.set_mode(MODE_DRIVE)
sm.set_mode(MODE_ESTOP) sm.set_mode(MODE_ESTOP)
mode_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_MODE) mode_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_MODE)
assert any(bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames) assert any(bytes(f.data) == bytes([MODE_ESTOP]) for f in mode_frames)
def test_estop_blocks_subsequent_drive(self, mock_can_bus): def test_estop_blocks_subsequent_drive(self, mock_can_bus):
@ -162,7 +162,7 @@ class TestDriveToEstop:
forwarded = sm.send_drive(1.0, 1.0) forwarded = sm.send_drive(1.0, 1.0)
assert forwarded is False assert forwarded is False
vel_frames = mock_can_bus.get_sent_frames_by_id(MAMBA_CMD_VELOCITY) vel_frames = mock_can_bus.get_sent_frames_by_id(BALANCE_CMD_VELOCITY)
assert len(vel_frames) == 0 assert len(vel_frames) == 0

View File

@ -5,11 +5,7 @@ Comprehensive hardware diagnostics and health monitoring for SaltyBot.
## Features ## Features
### Startup Checks ### Startup Checks
<<<<<<< HEAD
- RPLIDAR, RealSense, VESC, Jabra mic, ESP32 BALANCE, servos
=======
- RPLIDAR, RealSense, VESC, Jabra mic, ESP32-S3, servos - RPLIDAR, RealSense, VESC, Jabra mic, ESP32-S3, servos
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- WiFi, GPS, disk space, RAM - WiFi, GPS, disk space, RAM
- Boot result TTS + face animation - Boot result TTS + face animation
- JSON logging - JSON logging

View File

@ -1,3 +1,9 @@
# USB CDC Bug — ARCHIVED (legacy STM32 era)
> **ARCHIVED** — This document records a firmware bug from the legacy STM32F722/Mamba F722S era. The hardware has been replaced by ESP32-S3 BALANCE + IO boards.
---
# USB CDC TX Bug — Investigation & Resolution # USB CDC TX Bug — Investigation & Resolution
**Issue #524** | Investigated 2026-03-06 | **RESOLVED** (PR #10) **Issue #524** | Investigated 2026-03-06 | **RESOLVED** (PR #10)

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef BARO_H #ifndef BARO_H
#define BARO_H #define BARO_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef BATTERY_H #ifndef BATTERY_H
#define BATTERY_H #define BATTERY_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef BUZZER_H #ifndef BUZZER_H
#define BUZZER_H #define BUZZER_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef CONFIG_H #ifndef CONFIG_H
#define CONFIG_H #define CONFIG_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef FAN_H #ifndef FAN_H
#define FAN_H #define FAN_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef ORIN_CAN_H #ifndef ORIN_CAN_H
#define ORIN_CAN_H #define ORIN_CAN_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef OTA_H #ifndef OTA_H
#define OTA_H #define OTA_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef PID_FLASH_H #ifndef PID_FLASH_H
#define PID_FLASH_H #define PID_FLASH_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef STEERING_PID_H #ifndef STEERING_PID_H
#define STEERING_PID_H #define STEERING_PID_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef ULTRASONIC_H #ifndef ULTRASONIC_H
#define ULTRASONIC_H #define ULTRASONIC_H

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#ifndef VESC_CAN_H #ifndef VESC_CAN_H
#define VESC_CAN_H #define VESC_CAN_H

View File

@ -1,30 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SaltyLab Firmware OTA Flash Script — Issue #124 """SaltyLab Firmware OTA Flash Script — Issue #124
Flashes firmware via USB DFU using dfu-util. Flashes ESP32-S3 firmware via PlatformIO (pio run -t upload).
Supports CRC32 integrity verification and host-side backup/rollback. Supports CRC32 integrity verification and host-side backup/rollback.
Usage: Usage:
python flash_firmware.py firmware.bin [options] python flash_firmware.py firmware.bin [options]
python flash_firmware.py --rollback python flash_firmware.py --rollback
python flash_firmware.py firmware.bin --trigger-dfu /dev/ttyUSB0 python flash_firmware.py --board balance # flash esp32/balance/ via PlatformIO
python flash_firmware.py --board io # flash esp32/io/ via PlatformIO
Options: Options:
--vid HEX USB vendor ID (default: 0x0483 STMicroelectronics) --board NAME Board to flash: 'balance' (ESP32-S3 BALANCE) or 'io' (ESP32-S3 IO)
--pid HEX USB product ID (default: 0xDF11 DFU mode)
--alt N DFU alt setting (default: 0 internal flash)
--rollback Flash the previous firmware backup --rollback Flash the previous firmware backup
--trigger-dfu PORT Send DFU_ENTER over JLink UART before flashing --dry-run Print pio command but do not execute
--dry-run Print dfu-util command but do not execute
Requirements: Requirements:
pip install pyserial (only if using --trigger-dfu) pip install platformio (PlatformIO CLI)
dfu-util >= 0.9 installed and in PATH pio >= 6.x installed and in PATH
Dual-bank note: ESP32-S3 note:
ESP32 has single-bank 512 KB flash; hardware A/B rollback is not ESP32-S3 BALANCE board uses CH343G USB bridge flashing via USB UART.
supported. Rollback is implemented here by saving a backup of the ESP32-S3 IO board uses built-in JTAG/USB-CDC.
previous binary (.firmware_backup.bin) before each flash. Both flashed with: pio run -t upload in the respective esp32/ subdirectory.
""" """
import argparse import argparse
@ -36,13 +34,18 @@ import subprocess
import sys import sys
import time import time
# ---- ESP32 flash constants ---- # ---- ESP32-S3 flash constants ----
FLASH_BASE = 0x08000000 # ESP32-S3 flash is managed by PlatformIO/esptool; these values are kept
FLASH_SIZE = 0x80000 # 512 KB # for reference only (CRC utility functions below are still valid for
# cross-checking firmware images).
FLASH_BASE = 0x00000000
FLASH_SIZE = 0x800000 # 8 MB
# ---- DFU device defaults (ESP32/STM32 system bootloader) ---- # ---- PlatformIO board directories ----
DFU_VID = 0x0483 # STMicroelectronics BOARD_DIRS = {
DFU_PID = 0xDF11 # DFU mode "balance": "esp32/balance",
"io": "esp32/io",
}
BACKUP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), BACKUP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'.firmware_backup.bin') '.firmware_backup.bin')
@ -60,20 +63,12 @@ def crc32_file(path: str) -> int:
return binascii.crc32(data) & 0xFFFFFFFF return binascii.crc32(data) & 0xFFFFFFFF
def stm32_crc32(data: bytes) -> int: def esp32_crc32(data: bytes) -> int:
""" """
Compute CRC-32/MPEG-2 matching ESP32 hardware CRC unit. Compute CRC-32/MPEG-2 for firmware image integrity verification.
ESP32/STM32 algorithm: Note: ESP32-S3 uses esptool for flashing; this CRC is for host-side
Polynomial : 0x04C11DB7 integrity checking only (not the ESP32 hardware CRC unit).
Initial : 0xFFFFFFFF
Width : 32 bits
Reflection : none (MSB-first)
Feed size : 32-bit words from flash (little-endian CPU read)
When the ESP32 BALANCE reads a flash word it gets a little-endian uint32;
the hardware CRC unit processes bits[31:24] first, then [23:16],
[15:8], [7:0]. This Python implementation replicates that behaviour.
data should be padded to a 4-byte boundary with 0xFF before calling. data should be padded to a 4-byte boundary with 0xFF before calling.
""" """
@ -135,24 +130,19 @@ def trigger_dfu_via_jlink(port: str, baud: int = 921600) -> None:
# ---- Flash ---- # ---- Flash ----
def flash(bin_path: str, vid: int, pid: int, alt: int = 0, def flash(bin_path: str, board: str = "balance",
dry_run: bool = False) -> int: dry_run: bool = False) -> int:
""" """
Flash firmware using dfu-util. Returns the process exit code. Flash firmware using PlatformIO (pio run -t upload).
Returns the process exit code.
Uses --dfuse-address with :leave to reset into application after flash. board: 'balance' esp32/balance/, 'io' esp32/io/
""" """
addr = f'0x{FLASH_BASE:08X}' board_dir = BOARD_DIRS.get(board, BOARD_DIRS["balance"])
cmd = [ cmd = ['pio', 'run', '-t', 'upload', '--project-dir', board_dir]
'dfu-util',
'--device', f'{vid:04x}:{pid:04x}',
'--alt', str(alt),
'--dfuse-address', f'{addr}:leave',
'--download', bin_path,
]
print('Running:', ' '.join(cmd)) print('Running:', ' '.join(cmd))
if dry_run: if dry_run:
print('[dry-run] skipping dfu-util execution') print('[dry-run] skipping pio upload execution')
return 0 return 0
return subprocess.call(cmd) return subprocess.call(cmd)
@ -161,31 +151,19 @@ def flash(bin_path: str, vid: int, pid: int, alt: int = 0,
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='SaltyLab firmware OTA flash via USB DFU (Issue #124)' description='SaltyLab ESP32-S3 firmware flash via PlatformIO (Issue #124)'
) )
parser.add_argument('firmware', nargs='?', parser.add_argument('firmware', nargs='?',
help='Firmware .bin file to flash') help='Firmware .bin file (for CRC check only; PlatformIO handles actual flash)')
parser.add_argument('--vid', type=lambda x: int(x, 0), default=DFU_VID, parser.add_argument('--board', default='balance',
help=f'USB vendor ID (default: 0x{DFU_VID:04X})') choices=['balance', 'io'],
parser.add_argument('--pid', type=lambda x: int(x, 0), default=DFU_PID, help='Board to flash: balance (ESP32-S3 BALANCE) or io (ESP32-S3 IO)')
help=f'USB product ID (default: 0x{DFU_PID:04X})')
parser.add_argument('--alt', type=int, default=0,
help='DFU alt setting (default: 0 — internal flash)')
parser.add_argument('--rollback', action='store_true', parser.add_argument('--rollback', action='store_true',
help='Flash the previous firmware backup') help='Flash the previous firmware backup')
parser.add_argument('--trigger-dfu', metavar='PORT',
help='Trigger DFU via JLink UART before flashing '
'(e.g. /dev/ttyUSB0 or COM3)')
parser.add_argument('--dry-run', action='store_true', parser.add_argument('--dry-run', action='store_true',
help='Print dfu-util command without executing it') help='Print pio command without executing it')
args = parser.parse_args() args = parser.parse_args()
# Optionally trigger DFU mode over JLink serial
if args.trigger_dfu:
trigger_dfu_via_jlink(args.trigger_dfu)
print('Waiting 3 s for USB DFU enumeration…')
time.sleep(3)
# Determine target binary # Determine target binary
if args.rollback: if args.rollback:
if not os.path.exists(BACKUP_PATH): if not os.path.exists(BACKUP_PATH):
@ -214,23 +192,23 @@ def main() -> int:
f'({FLASH_SIZE} bytes)', file=sys.stderr) f'({FLASH_SIZE} bytes)', file=sys.stderr)
return 1 return 1
# ESP32/STM32 hardware CRC (for cross-checking with firmware telemetry) # CRC for cross-checking firmware integrity
with open(target, 'rb') as fh: with open(target, 'rb') as fh:
bin_data = fh.read() bin_data = fh.read()
crc_hw = stm32_crc32(bin_data.ljust(FLASH_SIZE, b'\xff')) crc_hw = esp32_crc32(bin_data.ljust(FLASH_SIZE, b'\xff'))
print(f'CRC-32 : 0x{crc_hw:08X} (MPEG-2 / ESP32/STM32 HW, padded to {FLASH_SIZE // 1024} KB)') print(f'CRC-32 : 0x{crc_hw:08X} (MPEG-2, padded to {FLASH_SIZE // 1024} KB)')
# Save backup before flashing (skip when rolling back) # Save backup before flashing (skip when rolling back)
if not args.rollback: if not args.rollback:
shutil.copy2(target, BACKUP_PATH) shutil.copy2(target, BACKUP_PATH)
print(f'Backup : {BACKUP_PATH}') print(f'Backup : {BACKUP_PATH}')
# Flash # Flash via PlatformIO
rc = flash(target, args.vid, args.pid, args.alt, args.dry_run) rc = flash(target, args.board, args.dry_run)
if rc == 0: if rc == 0:
print('Flash complete — device should reset into application.') print('Flash complete — ESP32-S3 should reset into application.')
else: else:
print(f'ERROR: dfu-util exited with code {rc}', file=sys.stderr) print(f'ERROR: pio run -t upload exited with code {rc}', file=sys.stderr)
return rc return rc

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#include "audio.h" #include "audio.h"
#include "config.h" #include "config.h"
#include "stm32f7xx_hal.h" #include "stm32f7xx_hal.h"

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
/* /*
* baro.c BME280/BMP280 barometric pressure & ambient temperature module * baro.c BME280/BMP280 barometric pressure & ambient temperature module
* (Issue #672). * (Issue #672).

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
/* /*
* battery.c Vbat ADC reading for CRSF telemetry uplink (Issue #103) * battery.c Vbat ADC reading for CRSF telemetry uplink (Issue #103)
* *

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
/* MPU6000 + ICM-42688-P dual driver — auto-detects based on WHO_AM_I */ /* MPU6000 + ICM-42688-P dual driver — auto-detects based on WHO_AM_I */
#include "stm32f7xx_hal.h" #include "stm32f7xx_hal.h"
#include "config.h" #include "config.h"

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
/* imu_cal_flash.c — IMU mount angle calibration flash storage (Issue #680) /* imu_cal_flash.c — IMU mount angle calibration flash storage (Issue #680)
* *
* Stores pitch/roll mount offsets in STM32F722 flash sector 7 at 0x0807FF00. * Stores pitch/roll mount offsets in STM32F722 flash sector 7 at 0x0807FF00.

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#include "led.h" #include "led.h"
#include "config.h" #include "config.h"
#include "stm32f7xx_hal.h" #include "stm32f7xx_hal.h"

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
/* /*
* mpu6000.c IMU Sensor Fusion for MPU6000 * mpu6000.c IMU Sensor Fusion for MPU6000
* *

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era code. NOT used in current hardware. */
#include "power_mgmt.h" #include "power_mgmt.h"
#include "config.h" #include "config.h"
#include "stm32f7xx_hal.h" #include "stm32f7xx_hal.h"

View File

@ -1,3 +1,4 @@
/* ARCHIVED: Legacy STM32F722/Mamba F722S era tests. NOT used with current hardware. */
/* /*
* test_vesc_can.c Unit tests for VESC CAN protocol driver (Issue #674). * test_vesc_can.c Unit tests for VESC CAN protocol driver (Issue #674).
* *
@ -12,8 +13,8 @@
/* ---- Block HAL and board-specific headers ---- */ /* ---- Block HAL and board-specific headers ---- */
/* Must appear before any board include is transitively pulled */ /* Must appear before any board include is transitively pulled */
#define STM32F7XX_HAL_H /* skip stm32f7xx_hal.h */ #define STM32F7XX_HAL_H /* LEGACY: skip stm32f7xx_hal.h */
#define STM32F722xx /* satisfy any chip guard */ #define STM32F722xx /* LEGACY: satisfy any chip guard */
#define JLINK_H /* skip jlink.h (pid_flash / HAL deps) */ #define JLINK_H /* skip jlink.h (pid_flash / HAL deps) */
#define CAN_DRIVER_H /* skip can_driver.h body (we stub functions below) */ #define CAN_DRIVER_H /* skip can_driver.h body (we stub functions below) */

View File

@ -16,11 +16,7 @@
| Depth Cam | Intel RealSense D435i — 848×480 @ 90fps, BMI055 IMU | | Depth Cam | Intel RealSense D435i — 848×480 @ 90fps, BMI055 IMU |
| LIDAR | RPLIDAR A1M8 — 360° 2D, 12m range, ~5.5 Hz | | LIDAR | RPLIDAR A1M8 — 360° 2D, 12m range, ~5.5 Hz |
| Wide Cams | 4× IMX219 160° CSI — front/right/rear/left 90° intervals *(arriving)* | | Wide Cams | 4× IMX219 160° CSI — front/right/rear/left 90° intervals *(arriving)* |
<<<<<<< HEAD | BALANCE | ESP32-S3 BALANCE — CAN bus (CANable 2.0, can0, 500 kbps) |
| FC | ESP32 — UART bridge `/dev/ttyACM0` @ 921600 |
=======
| FC | ESP32-S3 — UART bridge `/dev/ttyACM0` @ 921600 |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -80,11 +76,7 @@ Jetson Orin Nano Super (Ubuntu 22.04 / JetPack 6 / CUDA 12.x)
Nav2 stack (Phase 2b) Nav2 stack (Phase 2b)
20Hz costmap 20Hz costmap
<<<<<<< HEAD /cmd_vel → CAN 0x300
/cmd_vel → ESP32 BALANCE
=======
/cmd_vel → ESP32-S3
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
4× IMX219 CSI (Phase 2c — pending hardware) 4× IMX219 CSI (Phase 2c — pending hardware)
front/right/rear/left 160° front/right/rear/left 160°

View File

@ -1,4 +1,5 @@
""" """
ARCHIVED: Legacy STM32F722/Mamba F722S era tests. NOT used with current hardware.
test_power_mgmt.py unit tests for Issue #178 power management module. test_power_mgmt.py unit tests for Issue #178 power management module.
Models the PM state machine, LED brightness, peripheral gating, current Models the PM state machine, LED brightness, peripheral gating, current
@ -471,7 +472,7 @@ class TestJlinkProtocol:
# Tests: Wake latency and IWDG budget # Tests: Wake latency and IWDG budget
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWakeLatencyBudget: class TestWakeLatencyBudget:
# ESP32-S3 STOP-mode wakeup: HSI ready ~2 ms + PLL lock ~2 ms ≈ 4 ms # LEGACY: STM32F722 STOP-mode (archived) wakeup: HSI ready ~2 ms + PLL lock ~2 ms ≈ 4 ms
ESTIMATED_WAKE_MS = 10 # conservative upper bound ESTIMATED_WAKE_MS = 10 # conservative upper bound
def test_wake_latency_within_50ms(self): def test_wake_latency_within_50ms(self):
@ -493,7 +494,7 @@ class TestWakeLatencyBudget:
assert PM_FADE_MS < PM_IDLE_TIMEOUT_MS assert PM_FADE_MS < PM_IDLE_TIMEOUT_MS
def test_stop_mode_wake_much_less_than_50ms(self): def test_stop_mode_wake_much_less_than_50ms(self):
# PLL startup on ESP32-S3: HSI on (0 ms, already running) + # LEGACY: STM32F722 STOP-mode (archived) — PLL startup on STM32F7: HSI on (0 ms, already running) +
# PLL lock ~2 ms + SysTick re-init ~0.1 ms ≈ 3 ms # PLL lock ~2 ms + SysTick re-init ~0.1 ms ≈ 3 ms
pll_lock_ms = 3 pll_lock_ms = 3
overhead_ms = 1 overhead_ms = 1
@ -539,7 +540,7 @@ class TestHardwareConstants:
assert 216 / 2 == 108 assert 216 / 2 == 108
def test_flash_latency_7_required_at_216mhz(self): def test_flash_latency_7_required_at_216mhz(self):
"""ESP32-S3 at 2.7-3.3 V: 7 wait states for 210-216 MHz.""" """STM32F7 at 2.7-3.3 V: 7 wait states for 210-216 MHz."""
FLASH_LATENCY = 7 FLASH_LATENCY = 7
assert FLASH_LATENCY == 7 assert FLASH_LATENCY == 7

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Saltybot — System Diagnostics</title> <title>Saltybot — System Diagnostics</title>
<link rel="stylesheet" href="diagnostics_panel.css"> <link rel="stylesheet" href="diagnostics_panel.css">
<script src="https://cdn.jsdelivr.net/npm/roslib@1.4.0/build/roslib.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
</head> </head>
<body> <body>
@ -112,7 +112,7 @@
<div class="temp-bar-track"><div class="temp-bar-fill" id="gpu-temp-bar" style="width:0%"></div></div> <div class="temp-bar-track"><div class="temp-bar-fill" id="gpu-temp-bar" style="width:0%"></div></div>
</div> </div>
<div class="temp-box" id="board-temp-box"> <div class="temp-box" id="board-temp-box">
<div class="temp-label">Board / ESP32-S3</div> <div class="temp-label">ESP32-S3</div>
<div class="temp-value" id="board-temp-val"></div> <div class="temp-value" id="board-temp-val"></div>
<div class="temp-bar-track"><div class="temp-bar-fill" id="board-temp-bar" style="width:0%"></div></div> <div class="temp-bar-track"><div class="temp-bar-fill" id="board-temp-bar" style="width:0%"></div></div>
</div> </div>