Compare commits

..

2 Commits

Author SHA1 Message Date
sl-uwb
c958cf4474 chore: complete legacy hardware cleanup — zero Mamba/STM32/BlackPill refs
Some checks failed
social-bot integration tests / Lint (flake8 + pep257) (pull_request) Failing after 2s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (pull_request) Has been skipped
social-bot integration tests / Latency profiling (GPU, Orin) (pull_request) Has been cancelled
Final pass after sl-firmware/cleanup-legacy-hw initial sweep:

Deleted:
- legacy/stm32/ (entire archive — all STM32 firmware, HAL stubs, tests,
  platformio.ini, USB_CDC lib — moved there by prior commit, now removed)
- test/test_ota.py (STM32 JLink/DFU OTA tests — imports from deleted
  legacy/stm32/scripts/; irrelevant to ESP32 OTA mechanism)

Updated:
- docs/wiring-diagram.md: remove "Mamba F722S / STM32 retired" banner
  and obsolete STM32 UART pin table (PA2/PB6/etc.); replace with
  ESP32-S3 GPIO table per SAUL-TEE-SYSTEM-REFERENCE.md
- docs/AGENTS.md: remove "archived STM32 HAL code" note
- TEAM.md: remove legacy/stm32/USB_CDC_BUG.md references
- serial_bridge_node.py: "stm32_serial_bridge" → "esp32_serial_bridge"

Result: grep for mamba|f722|stm32f7|stm32f4|blackpill across all
*.py *.cpp *.h *.c *.md *.yaml *.ini *.json returns zero hits
(excluding docs/SAUL-TEE-SYSTEM-REFERENCE.md superseded-hw table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:15:04 -04:00
sl-uwb
bb35ddd56d chore: resolve git conflict markers and complete legacy STM32/Mamba → ESP32-S3 rename
- Resolve 73 committed conflict markers (bulk resolution taking theirs/ESP32 side)
- Rename all MAMBA_CMD_* → BALANCE_CMD_*, MAMBA_TELEM_* → BALANCE_TELEM_*
- Rename FC_STATUS/VESC/IMU/BARO → BALANCE_STATUS/VESC/IMU/BARO in protocol_defs.py
- Update can_bridge_node.py: fix imports, replace legacy encode/decode calls with
  balance_protocol equivalents (encode_velocity_cmd, encode_mode_cmd, decode_imu_telem,
  decode_battery_telem, decode_vesc_state); fix watchdog and destroy_node
- Rename stm32_protocol.py/stm32_cmd_node.py → esp32_protocol.py/esp32_cmd_node.py
- Delete mamba_protocol.py; stm32_cmd.launch.py/stm32_cmd_params.yaml archived
- Update can_bridge_params.yaml: mamba_can_id → balance_can_id
- Update docs/AGENTS.md, SALTYLAB.md, wiring-diagram.md for ESP32-S3 architecture
- Update test/test_ota.py sys.path to legacy/stm32/scripts/flash_firmware.py
- No legacy STM32/Mamba refs remain outside legacy/ and SAUL-TEE-SYSTEM-REFERENCE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:11:24 -04:00
294 changed files with 587 additions and 32451 deletions

View File

@ -1,162 +0,0 @@
# .gitea/workflows/ota-release.yml
# Gitea Actions — ESP32 OTA firmware build & release (bd-9kod)
#
# Triggers on signed release tags:
# esp32-balance/vX.Y.Z → builds esp32s3/balance/ (ESP32-S3 Balance board)
# esp32-io/vX.Y.Z → builds esp32s3-io/ (ESP32-S3 IO board)
#
# Uses the official espressif/idf Docker image for reproducible builds.
# Attaches <app>_<version>.bin + <app>_<version>.sha256 to the Gitea release.
# The ESP32 Balance OTA system fetches the .bin from the release asset URL.
name: OTA release — build & attach firmware
on:
push:
tags:
- "esp32-balance/v*"
- "esp32-io/v*"
permissions:
contents: write
jobs:
build-and-release:
name: Build ${{ github.ref_name }}
runs-on: ubuntu-latest
container:
image: espressif/idf:v5.2.2
options: --user root
steps:
# ── 1. Checkout ───────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
# ── 2. Resolve build target from tag ─────────────────────────────────────
# Tag format: esp32-balance/v1.2.3 or esp32-io/v1.2.3
- name: Resolve project from tag
id: proj
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
case "$TAG" in
esp32-balance/*)
DIR="esp32s3/balance"
APP="esp32s3_balance"
;;
esp32-io/*)
DIR="esp32s3-io"
APP="esp32s3_io"
;;
*)
echo "::error::Unrecognised tag prefix: ${TAG}"
exit 1
;;
esac
VERSION="${TAG#*/}"
echo "dir=${DIR}" >> "$GITHUB_OUTPUT"
echo "app=${APP}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Build: ${APP} ${VERSION} from ${DIR}"
# ── 3. Build with ESP-IDF ─────────────────────────────────────────────────
- name: Build firmware (idf.py build)
shell: bash
run: |
. "${IDF_PATH}/export.sh"
cd "${{ steps.proj.outputs.dir }}"
idf.py build
# ── 4. Collect binary & generate checksum ────────────────────────────────
- name: Collect artifacts
id: art
shell: bash
run: |
APP="${{ steps.proj.outputs.app }}"
VER="${{ steps.proj.outputs.version }}"
BIN_SRC="${{ steps.proj.outputs.dir }}/build/${APP}.bin"
BIN_OUT="${APP}_${VER}.bin"
SHA_OUT="${APP}_${VER}.sha256"
cp "$BIN_SRC" "$BIN_OUT"
sha256sum "$BIN_OUT" > "$SHA_OUT"
echo "bin=${BIN_OUT}" >> "$GITHUB_OUTPUT"
echo "sha=${SHA_OUT}" >> "$GITHUB_OUTPUT"
echo "Binary: ${BIN_OUT} ($(wc -c < "$BIN_OUT") bytes)"
echo "Checksum: $(cat "$SHA_OUT")"
# ── 5. Archive artifacts in CI workspace ─────────────────────────────────
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-${{ steps.proj.outputs.app }}-${{ steps.proj.outputs.version }}
path: |
${{ steps.art.outputs.bin }}
${{ steps.art.outputs.sha }}
# ── 6. Create Gitea release (if needed) & upload assets ──────────────────
# Uses GITHUB_TOKEN (auto-provided, contents:write from permissions block).
# URL-encodes the tag to handle the slash in esp32-balance/vX.Y.Z.
- name: Publish assets to Gitea release
shell: bash
env:
GITEA_URL: https://gitea.vayrette.com
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ steps.proj.outputs.tag }}
BIN: ${{ steps.art.outputs.bin }}
SHA: ${{ steps.art.outputs.sha }}
run: |
API="${GITEA_URL}/api/v1/repos/${REPO}"
# URL-encode the tag (slash in esp32-balance/vX.Y.Z must be escaped)
TAG_ENC=$(python3 -c "
import urllib.parse, sys
print(urllib.parse.quote(sys.argv[1], safe=''))
" "$TAG")
# Try to fetch an existing release for this tag
RELEASE=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG_ENC}") || true
# If no release yet, create it
if [ -z "$RELEASE" ]; then
echo "Creating release for tag: ${TAG}"
RELEASE=$(curl -sf \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c "
import json, sys
print(json.dumps({
'tag_name': sys.argv[1],
'name': sys.argv[1],
'draft': False,
'prerelease': False,
}))
" "$TAG")" \
"${API}/releases")
fi
RELEASE_ID=$(echo "$RELEASE" | python3 -c "
import sys, json; print(json.load(sys.stdin)['id'])
")
echo "Release ID: ${RELEASE_ID}"
# Upload binary and checksum
for FILE in "$BIN" "$SHA"; do
FNAME=$(basename "$FILE")
echo "Uploading: ${FNAME}"
curl -sf \
-X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${FILE}" \
"${API}/releases/${RELEASE_ID}/assets?name=${FNAME}"
done
echo "Published: ${BIN} + ${SHA} → release ${TAG}"

View File

@ -7,12 +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 USB Serial (CH343) to the ESP32-S3 firmware- Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
- 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
- Works even when RC is not connected or not armed - Works even when RC is not connected or not armed
### RC Arming (Optional Override) ### RC Arming (Optional Override)
@ -46,12 +41,7 @@ The robot can now be armed and operated autonomously from the Jetson without req
## Command Protocol ## Command Protocol
<<<<<<< HEAD ### From Jetson to ESP32-S3 (USB Serial (CH343))```
### From Jetson to ESP32 BALANCE (USB CDC)
=======
### From Jetson to ESP32-S3 (USB Serial (CH343))
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```
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)
E — Emergency stop (immediate motor cutoff, latched) E — Emergency stop (immediate motor cutoff, latched)
@ -60,12 +50,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 to Jetson (USB Serial (CH343))Motor commands are gated by `bal.state == BALANCE_ARMED`:
### 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`:
- 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)

View File

@ -1,34 +1,13 @@
# SaltyLab Firmware — Agent Playbook # SaltyLab Firmware — Agent Playbook
## Project ## Project
<<<<<<< HEAD
**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. 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, 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-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 Nano Super, 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 Serial (CH343) TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG ordering fix).

36
TEAM.md
View File

@ -1,54 +1,29 @@
# SaltyLab — Ideal Team # SaltyLab — Ideal Team
## Project ## Project
<<<<<<< HEAD
**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
- **Hardware:** ESP32-S3 BALANCE (Waveshare Touch LCD 1.28, CH343 USB) + ESP32-S3 IO (bare devkit, JTAG USB)
- **Firmware:** ESP-IDF/PlatformIO target; legacy `src/` STM32 HAL archived
- **Comms:** UART 460800 baud inter-board; CANable2 USB→CAN for Orin; CAN 500 kbps to VESCs (L:68 / R:56)
=======
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. 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 ## Current Status
- **Hardware:** Assembled — FC, motors, ESC, IMU, battery, RC all on hand - **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 - **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 - **Blocker:** USB Serial (CH343) TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB on ESP32-S3
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
## Roles Needed ## Roles Needed
### 1. Embedded Firmware Engineer (Lead) ### 1. Embedded Firmware Engineer (Lead)
**Must-have:** **Must-have:**
<<<<<<< HEAD
- Deep ESP32 (Arduino/ESP-IDF) or STM32 HAL experience
- USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts)
- SPI + UART + USB coexistence on ESP32
- PlatformIO or bare-metal ESP32 toolchain
- DFU bootloader implementation
=======
- Deep ESP-IDF experience (ESP32-S3 specifically) - Deep ESP-IDF experience (ESP32-S3 specifically)
- USB Serial (CH343) / UART debugging on ESP32-S3 - USB Serial (CH343) / UART debugging on ESP32-S3
- SPI + UART + USB coexistence on ESP32-S3 - SPI + UART + USB coexistence on ESP32-S3
- ESP-IDF / Arduino-ESP32 toolchain - ESP-IDF / Arduino-ESP32 toolchain
- OTA firmware update implementation - 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) - ESP32-S3 peripheral coexistence (SPI + UART + USB)
- PID control loop tuning for balance robots - PID control loop tuning for balance robots
- FOC motor control (hoverboard ESC protocol) - FOC motor control (hoverboard ESC protocol)
<<<<<<< HEAD
**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. **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:**
- PID tuning for inverted pendulum / self-balancing systems - PID tuning for inverted pendulum / self-balancing systems
@ -83,12 +58,7 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
## Hardware Reference ## Hardware Reference
| Component | Details | | Component | Details |
|-----------|---------| |-----------|---------|
<<<<<<< HEAD | FC | ESP32-S3 BALANCE (ESP32-S3RET6, QMI8658) || Motors | 2x 8" pneumatic hoverboard hub motors |
| FC | ESP32 BALANCE (ESP32RET6, MPU6000) |
=======
| FC | ESP32-S3 BALANCE (ESP32-S3RET6, QMI8658) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Motors | 2x 8" pneumatic hoverboard hub motors |
| ESC | Hoverboard ESC (EFeru FOC firmware) | | 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 |
@ -100,4 +70,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) - Design doc: `docs/SAUL-TEE-SYSTEM-REFERENCE.md`

View File

@ -56,16 +56,7 @@
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 FC mount (ESP32-S3 BALANCE)1. Place silicone anti-vibration grommets onto nylon M3 standoffs.
### 7 MCU mount (ESP32 BALANCE + ESP32 IO)
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE retired. Two ESP32 boards replace it.
> Board dimensions and hole patterns TBD — await spec from max before machining mount plate.
=======
### 7 FC mount (ESP32-S3 BALANCE)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
1. Place silicone anti-vibration grommets onto nylon M3 standoffs.
2. Lower ESP32 BALANCE board onto standoffs; secure with M3×6 BHCS. Snug only. 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. 3. Mount ESP32 IO board adjacent — exact placement TBD pending board dimensions.
4. Orient USB connectors toward front of robot for cable access. 4. Orient USB connectors toward front of robot for cable access.

View File

@ -41,12 +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 | FC standoff M3×6mm nylon | 4 | Nylon | — | ESP32-S3 BALANCE vibration isolation || 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment |
| 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 |
### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B ### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B
@ -97,19 +92,10 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| # | Part | Qty | Spec | Notes | | # | Part | Qty | Spec | Notes |
|---|------|-----|------|-------| |---|------|-----|------|-------|
<<<<<<< HEAD
| 13 | ESP32 BALANCE board | 1 | TBD — mount pattern TBD | PID balance loop; replaces ESP32 BALANCE |
| 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 |
| 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 |
=======
| 13 | ESP32-S3 ESP32-S3 BALANCE FC | 1 | 36×36mm PCB, 30.5×30.5mm M3 mount | Oriented USB-C port toward front | | 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 | | 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | FC vibration isolation |
| 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under FC mount pads | | 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 | | 16 | Jetson Orin Nano Super B01 module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern || 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs |
--- ---

View File

@ -104,12 +104,7 @@ 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 | FC (ESP32-S3 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK || ESC × 2 | Al pad lid | 100 °C Tj | Target 60 °C |
| FC (ESP32 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °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 |
| D435i | Passive; housing vent gap on rear cap | 45 °C surface | — | | D435i | Passive; housing vent gap on rear cap | 45 °C surface | — |
Fan spec: 40 mm, 12 V, ≥10 CFM at 0.1" H₂O static pressure. Fan spec: 40 mm, 12 V, ≥10 CFM at 0.1" H₂O static pressure.

View File

@ -4,24 +4,6 @@ You're working on **SaltyLab**, a self-balancing two-wheeled indoor robot. Read
## ⚠️ ARCHITECTURE — SAUL-TEE (finalised 2026-04-04) ## ⚠️ ARCHITECTURE — SAUL-TEE (finalised 2026-04-04)
<<<<<<< HEAD
Full hardware spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md` — **read it before writing firmware.**
| Board | Role |
|-------|------|
| **ESP32-S3 BALANCE** | Waveshare Touch LCD 1.28 (CH343 USB). QMI8658 IMU, PID loop, CAN→VESC L(68)/R(56), GC9A01 LCD |
| **ESP32-S3 IO** | Bare devkit (JTAG USB). TBS Crossfire RC (UART0), ELRS failover (UART2), BTS7960 motors, NFC/baro/ToF, WS2812, buzzer/horn/headlight/fan |
| **Jetson Orin** | CANable2 USB→CAN. Cmds on 0x3000x303, telemetry on 0x4000x401 |
```
Jetson Orin ──CANable2──► CAN 500kbps ◄───────────────────────┐
│ │
ESP32-S3 BALANCE ←─UART 460800─► ESP32-S3 IO
(QMI8658, PID loop) (BTS7960, RC, sensors)
│ CAN 500kbps
┌─────────┴──────────┐
VESC Left (ID 68) VESC Right (ID 56)
=======
A hoverboard-based balancing robot with two compute layers: A hoverboard-based balancing robot with two compute layers:
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. 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.
2. **Jetson Orin Nano Super** — AI brain. ROS2, SLAM, person tracking. Sends velocity commands to FC via UART. Not safety-critical — FC operates independently. 2. **Jetson Orin Nano Super** — AI brain. ROS2, SLAM, person tracking. Sends velocity commands to FC via UART. Not safety-critical — FC operates independently.
@ -33,12 +15,10 @@ Jetson (speed+steer via UART1) ←→ ELRS RC (UART3, kill switch)
ESP32-S3 BALANCE (MPU6000 IMU, PID balance) ESP32-S3 BALANCE (MPU6000 IMU, PID balance)
▼ UART2 ▼ UART2
Hoverboard ESC (FOC) → 2× 8" hub motors Hoverboard ESC (FOC) → 2× 8" hub motors```
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```
Frame: `[0xAA][LEN][TYPE][PAYLOAD][CRC8]` Frame: `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
Legacy `src/` STM32 HAL code is **archived — do not extend.** Active firmware: `esp32/balance_fw/` (ESP32-S3 BALANCE) and `esp32/io_fw/` (ESP32-S3 IO).
## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT ## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT
@ -57,12 +37,7 @@ This is not a toy. 8" hub motors + 36V battery can crush fingers, break toes, an
## Repository Layout ## Repository Layout
``` ```
<<<<<<< HEAD firmware/ # ESP-IDF firmware (PlatformIO)├── src/
firmware/ # Legacy ESP32/STM32 HAL firmware (PlatformIO, archived)
=======
firmware/ # ESP-IDF firmware (PlatformIO)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
├── src/
│ ├── main.c # Entry point, clock config, main loop │ ├── main.c # Entry point, clock config, main loop
│ ├── icm42688.c # QMI8658-P SPI driver (backup IMU — currently broken) │ ├── icm42688.c # QMI8658-P SPI driver (backup IMU — currently broken)
│ ├── bmp280.c # Barometer driver (disabled) │ ├── bmp280.c # Barometer driver (disabled)
@ -108,25 +83,16 @@ PLATFORM.md # Hardware platform reference
## Hardware Quick Reference ## Hardware Quick Reference
<<<<<<< HEAD
### ESP32 BALANCE Flight Controller
| Spec | Value |
|------|-------|
| MCU | ESP32RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
=======
### ESP32-S3 BALANCE Flight Controller ### ESP32-S3 BALANCE Flight Controller
| Spec | Value | | Spec | Value |
|------|-------| |------|-------|
| MCU | ESP32-S3RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) | | MCU | ESP32-S3RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) || Primary IMU | MPU6000 (WHO_AM_I = 0x68) |
>>>>>>> 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 Bus | SPI1: PA5=SCK, PA6=MISO, PA7=MOSI, CS=PA4 |
| IMU EXTI | PC4 (data ready interrupt) | | IMU EXTI | PC4 (data ready interrupt) |
| IMU Orientation | CW270 (Betaflight convention) | | IMU Orientation | CW270 (Betaflight convention) |
| Secondary IMU | QMI8658-P (on same SPI1, CS unknown — currently non-functional) | | Secondary IMU | QMI8658-P (on same SPI1, CS unknown — currently non-functional) |
| Betaflight Target | DIAT-MAMBAF722_2022B | | Board Name | Waveshare ESP32-S3 Touch LCD 1.28 |
| USB | OTG FS (PA11/PA12), enumerates as /dev/cu.usbmodemSALTY0011 | | USB | OTG FS (PA11/PA12), enumerates as /dev/cu.usbmodemSALTY0011 |
| VID/PID | 0x0483/0x5740 | | VID/PID | 0x0483/0x5740 |
| LEDs | PC15 (LED1), PC14 (LED2), active low | | LEDs | PC15 (LED1), PC14 (LED2), active low |
@ -194,12 +160,7 @@ PLATFORM.md # Hardware platform reference
### Critical Lessons Learned (DON'T REPEAT THESE) ### Critical Lessons Learned (DON'T REPEAT THESE)
1. **SysTick_Handler with HAL_IncTick() is MANDATORY** — without it, HAL_Delay() and every HAL timeout hangs forever. This bricked us multiple times. 1. **SysTick_Handler with HAL_IncTick() is MANDATORY** — without it, HAL_Delay() and every HAL timeout hangs forever. This bricked us multiple times.
<<<<<<< HEAD 2. **DCache breaks SPI on ESP32-S3** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.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.
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. 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. 5. **USB Serial (CH343) needs ReceivePacket() primed in CDC_Init** — without it, the OUT endpoint never starts listening. No data reception.
@ -210,19 +171,14 @@ The firmware supports reboot-to-DFU via USB command:
2. Firmware writes `0xDEADBEEF` to RTC backup register 0 2. Firmware writes `0xDEADBEEF` to RTC backup register 0
3. `NVIC_SystemReset()` — clean hardware reset 3. `NVIC_SystemReset()` — clean hardware reset
4. On boot, `checkForBootloader()` (called after `HAL_Init()`) reads the magic 4. On boot, `checkForBootloader()` (called after `HAL_Init()`) reads the magic
<<<<<<< HEAD 5. If magic found: clears it, remaps system memory, jumps to ESP32-S3 bootloader at `0x1FF00000`6. Board appears as DFU device, ready for `dfu-util` flash
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 ### Build & Flash
```bash ```bash
cd firmware/ cd firmware/
python3 -m platformio run # Build python3 -m platformio run # Build
dfu-util -a 0 -s 0x08000000:leave -D .pio/build/f722/firmware.bin # Flash esptool.py --port /dev/esp32-balance write_flash 0x0 firmware.bin # Flash
``` ```
Dev machine: mbpm4 (seb@192.168.87.40), PlatformIO project at `~/Projects/saltylab-firmware/` Dev machine: mbpm4 (seb@192.168.87.40), PlatformIO project at `~/Projects/saltylab-firmware/`

View File

@ -1,11 +1,6 @@
# Face LCD Animation System (Issue #507) # Face LCD Animation System (Issue #507)
<<<<<<< HEAD
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. 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
### Emotions ### Emotions
@ -86,12 +81,7 @@ STATUS → Echo current emotion + idle state
- Colors: Monochrome (1-bit) or RGB565 - Colors: Monochrome (1-bit) or RGB565
### Microcontroller ### Microcontroller
<<<<<<< HEAD - ESP32-S3xx (ESP32-S3 BALANCE)- Available UART: USART3 (PB10=TX, PB11=RX)
- ESP32xx (ESP32 BALANCE)
=======
- 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 - Clock: 216 MHz
## Animation Timing ## Animation Timing

View File

@ -102,11 +102,8 @@ balance loop, and drives the hoverboard ESC via UART. Jetson Orin Nano Super
sends velocity commands over UART1. ELRS receiver on UART3 provides RC sends velocity commands over UART1. ELRS receiver on UART3 provides RC
override and kill-switch capability. 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 The legacy STM32 firmware (STM32 era) has been archived to
`legacy/stm32/` and is no longer built or deployed. `legacy/stm32/` and is no longer built or deployed.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## LED Subsystem (ESP32-C3) ## LED Subsystem (ESP32-C3)

View File

@ -1,26 +1,18 @@
# SaltyLab / SAUL-TEE Wiring Reference # SaltyLab / SAUL-TEE Wiring Reference
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** Mamba F722S / STM32 retired.
> 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) > **Authoritative reference:** [`docs/SAUL-TEE-SYSTEM-REFERENCE.md`](SAUL-TEE-SYSTEM-REFERENCE.md)
> Historical STM32/Mamba wiring below is **obsolete** — retained for reference only. > New stack: **ESP32-S3 BALANCE** + **ESP32-S3 IO** + VESCs on 500 kbps CAN.
--- ---
## ~~System Overview~~ (OBSOLETE — see SAUL-TEE-SYSTEM-REFERENCE.md) ## System Overview
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ ORIN NANO SUPER │ │ ORIN NANO SUPER │
│ (Top Plate — 25W) │ │ (Top Plate — 25W) │
│ │ │ │
<<<<<<< HEAD │ USB-C ──── ESP32-S3 CDC (/dev/esp32-bridge, 921600 baud) ││ USB-A1 ─── RealSense D435i (USB 3.1) │
│ USB-A ──── CANable2 USB-CAN adapter (slcan0, 500 kbps) │
│ USB-A ──── ESP32-S3 IO (/dev/esp32-io, 460800 baud) │
=======
│ USB-C ──── ESP32-S3 CDC (/dev/esp32-bridge, 921600 baud) │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ USB-A1 ─── RealSense D435i (USB 3.1) │
│ USB-A2 ─── RPLIDAR A1M8 (via CP2102 adapter, 115200) │ │ USB-A2 ─── RPLIDAR A1M8 (via CP2102 adapter, 115200) │
│ USB-C* ─── SIM7600A 4G/LTE modem (ttyUSB0-2, AT cmds + PPP) │ │ USB-C* ─── SIM7600A 4G/LTE modem (ttyUSB0-2, AT cmds + PPP) │
│ USB ─────── Leap Motion Controller (hand/gesture tracking) │ │ USB ─────── Leap Motion Controller (hand/gesture tracking) │
@ -38,14 +30,8 @@
│ 500 kbps │ │ 500 kbps │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
<<<<<<< HEAD
│ ESP32-S3 BALANCE │
│ (Waveshare Touch LCD 1.28, Middle Plate) │
=======
│ ESP32-S3 BALANCE (FC) │ │ ESP32-S3 BALANCE (FC) │
│ (Middle Plate — foam mounted) │ │ (Middle Plate — foam mounted) ││ │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │
│ CAN bus ──── CANable2 → Orin (primary link, ISO 11898) │ │ CAN bus ──── CANable2 → Orin (primary link, ISO 11898) │
│ UART0 ──── Orin UART fallback (460800 baud, 3.3V) │ │ UART0 ──── Orin UART fallback (460800 baud, 3.3V) │
│ UART1 ──── VESC Left (CAN ID 56) via UART/CAN bridge │ │ UART1 ──── VESC Left (CAN ID 56) via UART/CAN bridge │
@ -77,29 +63,16 @@
## Wire-by-Wire Connections ## Wire-by-Wire Connections
<<<<<<< HEAD ### 1. Orin ↔ ESP32-S3 BALANCE (Primary: USB Serial via CH343)
### 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 | | From | To | Wire | Notes |
|------|----|------|-------| |------|----|------|-------|
| Orin USB-A | CANable2 USB | USB cable | SocketCAN slcan0 @ 500 kbps | | 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-H | ESP32-S3 BALANCE CAN-H | twisted pair | ISO 11898 differential |
| CANable2 CAN-L | ESP32-S3 BALANCE CAN-L | 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` - Device: `/dev/ttyACM0` → symlink `/dev/esp32-bridge`
- Baud: 921600, 8N1 - Baud: 921600, 8N1
- Protocol: JSON telemetry (FC→Orin), ASCII commands (Orin→FC) - 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) ### 2. Orin <-> ESP32-S3 BALANCE (Fallback: Hardware UART)
| Orin Pin | Signal | ESP32-S3 Pin | Notes | | Orin Pin | Signal | ESP32-S3 Pin | Notes |
@ -164,47 +137,14 @@ BATTERY (36V) ──┬── VESC Left (36V direct -> BLDC left motor)
| CANable2 | USB-CAN | USB-A | `/dev/canable2` -> `slcan0` | | CANable2 | USB-CAN | USB-A | `/dev/canable2` -> `slcan0` |
<<<<<<< HEAD ## ESP32-S3 BALANCE — UART Summary
## FC UART Summary (MAMBA F722S — OBSOLETE)
| Interface | Pins | Baud/Rate | Assignment | Notes |
|-----------|------|-----------|------------|-------|
| 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)
| UART | GPIO Pins | Baud | Assignment | Notes |
|------|-----------|------|------------|-------|
| UART0 (CRSF primary) | IO44=RX, IO43=TX | 400000 | TBS Crossfire RC | via ESP32-S3 IO board |
| UART1 (inter-board) | IO17=TX, IO18=RX | 460800 | ESP32-S3 IO ↔ BALANCE | binary `[0xAA][LEN][TYPE]` |
| CAN (SN65HVD230) | IO43=TX, IO44=RX | 500 kbps | VESCs + Orin CANable2 | ISO 11898 |
| USB Serial (CH343) | USB-C | 460800 | Orin primary | `/dev/balance-esp` |
### 7. ReSpeaker 2-Mic HAT (on Orin 40-pin header) ### 7. ReSpeaker 2-Mic HAT (on Orin 40-pin header)
@ -263,14 +203,7 @@ VESC Left CAN ID = 56 (0x38), VESC Right CAN ID = 68 (0x44).
| Device | Interface | Power Draw | | Device | Interface | Power Draw |
|--------|-----------|------------| |--------|-----------|------------|
<<<<<<< HEAD | ESP32-S3 BALANCE (CH343) | USB-C | ~0.5W (data only, BALANCE on 5V bus) || RealSense D435i | USB-A | ~1.5W (3.5W peak) |
| 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) | | RPLIDAR A1M8 | USB-A | ~2.6W (motor on) |
| SIM7600A | USB | ~1W idle, 3W TX peak | | SIM7600A | USB | ~1W idle, 3W TX peak |
| Leap Motion | USB-A | ~0.5W | | Leap Motion | USB-A | ~0.5W |
@ -294,25 +227,14 @@ Orin Nano Super delivers up to 25W --- USB peripherals are well within budget.
└──────┬───────┘ └──────┬───────┘
│ UART │ UART
┌────────────▼────────────┐ ┌────────────▼────────────┐
<<<<<<< HEAD │ ESP32-S3 BALANCE │ │ │
│ ESP32-S3 BALANCE │
│ (Waveshare LCD 1.28) │
=======
│ ESP32-S3 BALANCE │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │
│ QMI8658 -> Balance PID │ │ QMI8658 -> Balance PID │
│ RC -> Mode Manager │ │ RC -> Mode Manager │
│ Safety Monitor │ │ Safety Monitor │
│ │ │ │
└──┬──────────┬───────────┘ └──┬──────────┬───────────┘
<<<<<<< HEAD
CAN 500kbps─┘ └───── CAN bus / UART fallback
=======
USART2 ─────┘ └───── USB Serial (CH343) / USART6 USART2 ─────┘ └───── USB Serial (CH343) / USART6
26400 baud 921600 baud 26400 baud 921600 baud │ │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │
┌────┴────────────┐ ▼ ┌────┴────────────┐ ▼
│ CAN bus (500k) │ ┌───────────────────┐ │ CAN bus (500k) │ ┌───────────────────┐
├─ VESC Left 56 │ │ Orin Nano Super │ ├─ VESC Left 56 │ │ Orin Nano Super │

View File

@ -1,3 +0,0 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32s3_balance)

View File

@ -1,23 +0,0 @@
idf_component_register(
SRCS
"main.c"
"orin_serial.c"
"vesc_can.c"
"gc9a01.c"
"gitea_ota.c"
"ota_self.c"
"uart_ota.c"
"ota_display.c"
INCLUDE_DIRS "."
REQUIRES
esp_wifi
esp_http_client
esp_https_ota
nvs_flash
app_update
mbedtls
espressif__cjson
driver
freertos
esp_timer
)

View File

@ -1,50 +0,0 @@
#pragma once
/* ── ESP32-S3 BALANCE board — Waveshare ESP32-S3-Touch-LCD-1.28 pinout ─────
*
* Orin comms: CH343 USB-to-serial on UART0 (GPIO43/44) /dev/ttyACM0 on Orin
* VESC CAN: SN65HVD230 transceiver on GPIO15 (TX) / GPIO16 (RX)
* Display: GC9A01 on SPI2 BL=GPIO40, RST=GPIO12
*
* GPIO2 is NOT used for CAN it is free. Earlier versions had an erroneous
* conflict between DISP_BL (GPIO2) and VESC_CAN_TX (GPIO2); corrected here
* to match motor-test-firmware verified hardware layout (commit 8e66430).
*/
/* ── Orin serial (CH343 USB-to-UART, 1a86:55d3 on Orin side) ── */
#define ORIN_UART_PORT UART_NUM_0
#define ORIN_UART_BAUD 460800
#define ORIN_UART_TX_GPIO 43 /* ESP32 UART0 TX → CH343 RXD */
#define ORIN_UART_RX_GPIO 44 /* CH343 TXD → ESP32 UART0 RX */
#define ORIN_UART_RX_BUF 1024
#define ORIN_TX_QUEUE_DEPTH 16
/* ── Inter-board UART (ESP32 BALANCE ↔ ESP32 IO) ── */
#define IO_UART_TX_GPIO 17
#define IO_UART_RX_GPIO 18
/* ── VESC CAN TWAI (SN65HVD230 on Waveshare header GPIO15/16) ── */
#define VESC_CAN_TX_GPIO 15 /* ESP32 TWAI TX → SN65HVD230 TXD */
#define VESC_CAN_RX_GPIO 16 /* SN65HVD230 RXD → ESP32 TWAI RX */
#define VESC_CAN_RX_QUEUE 32
/* VESC node IDs */
#define VESC_ID_A 61u /* FRONT VESC — drive + telemetry (0x81) */
#define VESC_ID_B 79u /* REAR VESC — telemetry only (0x82) */
/* ── GC9A01 240×240 round display (Waveshare ESP32-S3-Touch-LCD-1.28, SPI2) ── */
#define DISP_DC_GPIO 8
#define DISP_CS_GPIO 9
#define DISP_SCK_GPIO 10
#define DISP_MOSI_GPIO 11
#define DISP_RST_GPIO 12
#define DISP_BL_GPIO 40
/* ── Safety / timing ── */
#define HB_TIMEOUT_MS 500u /* heartbeat watchdog: disarm if exceeded */
#define DRIVE_TIMEOUT_MS 500u /* drive command staleness timeout */
#define TELEM_STATUS_PERIOD_MS 100u /* 10 Hz status telemetry to Orin */
#define TELEM_VESC_PERIOD_MS 100u /* 10 Hz VESC telemetry to Orin */
/* ── Drive → VESC RPM scaling ── */
#define RPM_PER_SPEED_UNIT 5 /* speed_units=1000 → 5000 ERPM */

View File

@ -1,269 +0,0 @@
/* gc9a01.c — GC9A01 240×240 round LCD SPI driver (bd-1yr8 display bead) */
#include "gc9a01.h"
#include "config.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <string.h>
#include <math.h>
static const char *TAG = "gc9a01";
static spi_device_handle_t s_spi;
/* ── 5×7 bitmap font, one byte per column (bit0 = top row), ASCII 32..126 ── */
static const uint8_t s_font[95][5] = {
{0x00,0x00,0x00,0x00,0x00}, /* ' ' */ {0x00,0x00,0x5F,0x00,0x00}, /* '!' */
{0x00,0x07,0x00,0x07,0x00}, /* '"' */ {0x14,0x7F,0x14,0x7F,0x14}, /* '#' */
{0x24,0x2A,0x7F,0x2A,0x12}, /* '$' */ {0x23,0x13,0x08,0x64,0x62}, /* '%' */
{0x36,0x49,0x55,0x22,0x50}, /* '&' */ {0x00,0x05,0x03,0x00,0x00}, /* '\'' */
{0x00,0x1C,0x22,0x41,0x00}, /* '(' */ {0x00,0x41,0x22,0x1C,0x00}, /* ')' */
{0x14,0x08,0x3E,0x08,0x14}, /* '*' */ {0x08,0x08,0x3E,0x08,0x08}, /* '+' */
{0x00,0x50,0x30,0x00,0x00}, /* ',' */ {0x08,0x08,0x08,0x08,0x08}, /* '-' */
{0x00,0x60,0x60,0x00,0x00}, /* '.' */ {0x20,0x10,0x08,0x04,0x02}, /* '/' */
{0x3E,0x51,0x49,0x45,0x3E}, /* '0' */ {0x00,0x42,0x7F,0x40,0x00}, /* '1' */
{0x42,0x61,0x51,0x49,0x46}, /* '2' */ {0x21,0x41,0x45,0x4B,0x31}, /* '3' */
{0x18,0x14,0x12,0x7F,0x10}, /* '4' */ {0x27,0x45,0x45,0x45,0x39}, /* '5' */
{0x3C,0x4A,0x49,0x49,0x30}, /* '6' */ {0x01,0x71,0x09,0x05,0x03}, /* '7' */
{0x36,0x49,0x49,0x49,0x36}, /* '8' */ {0x06,0x49,0x49,0x29,0x1E}, /* '9' */
{0x00,0x36,0x36,0x00,0x00}, /* ':' */ {0x00,0x56,0x36,0x00,0x00}, /* ';' */
{0x08,0x14,0x22,0x41,0x00}, /* '<' */ {0x14,0x14,0x14,0x14,0x14}, /* '=' */
{0x00,0x41,0x22,0x14,0x08}, /* '>' */ {0x02,0x01,0x51,0x09,0x06}, /* '?' */
{0x32,0x49,0x79,0x41,0x3E}, /* '@' */ {0x7E,0x11,0x11,0x11,0x7E}, /* 'A' */
{0x7F,0x49,0x49,0x49,0x36}, /* 'B' */ {0x3E,0x41,0x41,0x41,0x22}, /* 'C' */
{0x7F,0x41,0x41,0x22,0x1C}, /* 'D' */ {0x7F,0x49,0x49,0x49,0x41}, /* 'E' */
{0x7F,0x09,0x09,0x09,0x01}, /* 'F' */ {0x3E,0x41,0x49,0x49,0x3A}, /* 'G' */
{0x7F,0x08,0x08,0x08,0x7F}, /* 'H' */ {0x00,0x41,0x7F,0x41,0x00}, /* 'I' */
{0x20,0x40,0x41,0x3F,0x01}, /* 'J' */ {0x7F,0x08,0x14,0x22,0x41}, /* 'K' */
{0x7F,0x40,0x40,0x40,0x40}, /* 'L' */ {0x7F,0x02,0x0C,0x02,0x7F}, /* 'M' */
{0x7F,0x04,0x08,0x10,0x7F}, /* 'N' */ {0x3E,0x41,0x41,0x41,0x3E}, /* 'O' */
{0x7F,0x09,0x09,0x09,0x06}, /* 'P' */ {0x3E,0x41,0x51,0x21,0x5E}, /* 'Q' */
{0x7F,0x09,0x19,0x29,0x46}, /* 'R' */ {0x46,0x49,0x49,0x49,0x31}, /* 'S' */
{0x01,0x01,0x7F,0x01,0x01}, /* 'T' */ {0x3F,0x40,0x40,0x40,0x3F}, /* 'U' */
{0x1F,0x20,0x40,0x20,0x1F}, /* 'V' */ {0x3F,0x40,0x38,0x40,0x3F}, /* 'W' */
{0x63,0x14,0x08,0x14,0x63}, /* 'X' */ {0x07,0x08,0x70,0x08,0x07}, /* 'Y' */
{0x61,0x51,0x49,0x45,0x43}, /* 'Z' */ {0x00,0x7F,0x41,0x41,0x00}, /* '[' */
{0x02,0x04,0x08,0x10,0x20}, /* '\\' */ {0x00,0x41,0x41,0x7F,0x00}, /* ']' */
{0x04,0x02,0x01,0x02,0x04}, /* '^' */ {0x40,0x40,0x40,0x40,0x40}, /* '_' */
{0x00,0x01,0x02,0x04,0x00}, /* '`' */ {0x20,0x54,0x54,0x54,0x78}, /* 'a' */
{0x7F,0x48,0x44,0x44,0x38}, /* 'b' */ {0x38,0x44,0x44,0x44,0x20}, /* 'c' */
{0x38,0x44,0x44,0x48,0x7F}, /* 'd' */ {0x38,0x54,0x54,0x54,0x18}, /* 'e' */
{0x08,0x7E,0x09,0x01,0x02}, /* 'f' */ {0x0C,0x52,0x52,0x52,0x3E}, /* 'g' */
{0x7F,0x08,0x04,0x04,0x78}, /* 'h' */ {0x00,0x44,0x7D,0x40,0x00}, /* 'i' */
{0x20,0x40,0x44,0x3D,0x00}, /* 'j' */ {0x7F,0x10,0x28,0x44,0x00}, /* 'k' */
{0x00,0x41,0x7F,0x40,0x00}, /* 'l' */ {0x7C,0x04,0x18,0x04,0x78}, /* 'm' */
{0x7C,0x08,0x04,0x04,0x78}, /* 'n' */ {0x38,0x44,0x44,0x44,0x38}, /* 'o' */
{0x7C,0x14,0x14,0x14,0x08}, /* 'p' */ {0x08,0x14,0x14,0x18,0x7C}, /* 'q' */
{0x7C,0x08,0x04,0x04,0x08}, /* 'r' */ {0x48,0x54,0x54,0x54,0x20}, /* 's' */
{0x04,0x3F,0x44,0x40,0x20}, /* 't' */ {0x3C,0x40,0x40,0x20,0x7C}, /* 'u' */
{0x1C,0x20,0x40,0x20,0x1C}, /* 'v' */ {0x3C,0x40,0x30,0x40,0x3C}, /* 'w' */
{0x44,0x28,0x10,0x28,0x44}, /* 'x' */ {0x0C,0x50,0x50,0x50,0x3C}, /* 'y' */
{0x44,0x64,0x54,0x4C,0x44}, /* 'z' */ {0x00,0x08,0x36,0x41,0x00}, /* '{' */
{0x00,0x00,0x7F,0x00,0x00}, /* '|' */ {0x00,0x41,0x36,0x08,0x00}, /* '}' */
{0x10,0x08,0x08,0x10,0x08}, /* '~' */
};
/* ── Static buffers (internal SRAM → DMA-safe) ── */
static uint8_t s_line_buf[240 * 2];
static uint8_t s_char_buf[5 * 5 * 7 * 5 * 2]; /* max scale=5: 25×35×2 */
/* ── Low-level SPI helpers ── */
static void write_cmd(uint8_t cmd)
{
gpio_set_level(DISP_DC_GPIO, 0);
spi_transaction_t t = { .length = 8, .flags = SPI_TRANS_USE_TXDATA };
t.tx_data[0] = cmd;
spi_device_polling_transmit(s_spi, &t);
}
static void write_bytes(const uint8_t *data, size_t len)
{
if (!len) return;
gpio_set_level(DISP_DC_GPIO, 1);
spi_transaction_t t = { .length = len * 8, .tx_buffer = data };
spi_device_polling_transmit(s_spi, &t);
}
static inline void write_byte(uint8_t b) { write_bytes(&b, 1); }
/* ── Address window ── */
static void set_window(int x1, int y1, int x2, int y2)
{
uint8_t d[4];
d[0] = x1 >> 8; d[1] = x1 & 0xFF; d[2] = x2 >> 8; d[3] = x2 & 0xFF;
write_cmd(0x2A); write_bytes(d, 4);
d[0] = y1 >> 8; d[1] = y1 & 0xFF; d[2] = y2 >> 8; d[3] = y2 & 0xFF;
write_cmd(0x2B); write_bytes(d, 4);
}
/* ── GC9A01 register init ── */
static void init_regs(void)
{
write_cmd(0xEF);
write_cmd(0xEB); write_byte(0x14);
write_cmd(0xFE);
write_cmd(0xEF);
write_cmd(0xEB); write_byte(0x14);
write_cmd(0x84); write_byte(0x40);
write_cmd(0x85); write_byte(0xFF);
write_cmd(0x86); write_byte(0xFF);
write_cmd(0x87); write_byte(0xFF);
write_cmd(0x88); write_byte(0x0A);
write_cmd(0x89); write_byte(0x21);
write_cmd(0x8A); write_byte(0x00);
write_cmd(0x8B); write_byte(0x80);
write_cmd(0x8C); write_byte(0x01);
write_cmd(0x8D); write_byte(0x01);
write_cmd(0x8E); write_byte(0xFF);
write_cmd(0x8F); write_byte(0xFF);
{ uint8_t d[] = {0x00,0x20}; write_cmd(0xB6); write_bytes(d,2); }
write_cmd(0x36); write_byte(0x08); /* MADCTL: normal, BGR */
write_cmd(0x3A); write_byte(0x05); /* COLMOD: 16-bit RGB565 */
{ uint8_t d[] = {0x08,0x08,0x08,0x08}; write_cmd(0x90); write_bytes(d,4); }
write_cmd(0xBD); write_byte(0x06);
write_cmd(0xBC); write_byte(0x00);
{ uint8_t d[] = {0x60,0x01,0x04}; write_cmd(0xFF); write_bytes(d,3); }
write_cmd(0xC3); write_byte(0x13);
write_cmd(0xC4); write_byte(0x13);
write_cmd(0xC9); write_byte(0x22);
write_cmd(0xBE); write_byte(0x11);
{ uint8_t d[] = {0x10,0x0E}; write_cmd(0xE1); write_bytes(d,2); }
{ uint8_t d[] = {0x21,0x0C,0x02}; write_cmd(0xDF); write_bytes(d,3); }
{ uint8_t d[] = {0x45,0x09,0x08,0x08,0x26,0x2A}; write_cmd(0xF0); write_bytes(d,6); }
{ uint8_t d[] = {0x43,0x70,0x72,0x36,0x37,0x6F}; write_cmd(0xF1); write_bytes(d,6); }
{ uint8_t d[] = {0x45,0x09,0x08,0x08,0x26,0x2A}; write_cmd(0xF2); write_bytes(d,6); }
{ uint8_t d[] = {0x43,0x70,0x72,0x36,0x37,0x6F}; write_cmd(0xF3); write_bytes(d,6); }
{ uint8_t d[] = {0x1B,0x0B}; write_cmd(0xED); write_bytes(d,2); }
write_cmd(0xAE); write_byte(0x77);
write_cmd(0xCD); write_byte(0x63);
{ uint8_t d[] = {0x07,0x07,0x04,0x0E,0x0F,0x09,0x07,0x08,0x03};
write_cmd(0x70); write_bytes(d,9); }
write_cmd(0xE8); write_byte(0x34);
{ uint8_t d[] = {0x18,0x0D,0xB7,0x18,0x0D,0x8B,0x88,0x08};
write_cmd(0x62); write_bytes(d,8); }
{ uint8_t d[] = {0x18,0x0D,0xB7,0x58,0x1E,0x0B,0x00,0xA7,0x88,0x08};
write_cmd(0x63); write_bytes(d,10); }
{ uint8_t d[] = {0x20,0x07,0x04}; write_cmd(0x64); write_bytes(d,3); }
{ uint8_t d[] = {0x10,0x85,0x80,0x00,0x00,0x4E,0x00};
write_cmd(0x74); write_bytes(d,7); }
{ uint8_t d[] = {0x3E,0x07}; write_cmd(0x98); write_bytes(d,2); }
write_cmd(0x35); /* TEON */
write_cmd(0x21); /* INVON */
write_cmd(0x11); /* SLPOUT */
vTaskDelay(pdMS_TO_TICKS(120));
write_cmd(0x29); /* DISPON */
vTaskDelay(pdMS_TO_TICKS(20));
}
/* ── Public init ── */
void gc9a01_init(void)
{
/* SPI bus */
spi_bus_config_t bus = {
.mosi_io_num = DISP_MOSI_GPIO,
.miso_io_num = -1,
.sclk_io_num = DISP_SCK_GPIO,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4096,
};
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO));
spi_device_interface_config_t dev = {
.clock_speed_hz = 40 * 1000 * 1000,
.mode = 0,
.spics_io_num = DISP_CS_GPIO,
.queue_size = 1,
};
ESP_ERROR_CHECK(spi_bus_add_device(SPI2_HOST, &dev, &s_spi));
/* DC, RST, BL GPIOs */
gpio_set_direction(DISP_DC_GPIO, GPIO_MODE_OUTPUT);
gpio_set_direction(DISP_RST_GPIO, GPIO_MODE_OUTPUT);
gpio_set_direction(DISP_BL_GPIO, GPIO_MODE_OUTPUT);
/* Hardware reset */
gpio_set_level(DISP_RST_GPIO, 0); vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(DISP_RST_GPIO, 1); vTaskDelay(pdMS_TO_TICKS(120));
init_regs();
/* Backlight on */
gpio_set_level(DISP_BL_GPIO, 1);
ESP_LOGI(TAG, "GC9A01 init OK: DC=%d CS=%d SCK=%d MOSI=%d RST=%d BL=%d",
DISP_DC_GPIO, DISP_CS_GPIO, DISP_SCK_GPIO, DISP_MOSI_GPIO,
DISP_RST_GPIO, DISP_BL_GPIO);
}
/* ── display_fill_rect ── */
void display_fill_rect(int x, int y, int w, int h, uint16_t rgb565)
{
if (w <= 0 || h <= 0) return;
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x + w > 240) w = 240 - x;
if (y + h > 240) h = 240 - y;
if (w <= 0 || h <= 0) return;
set_window(x, y, x + w - 1, y + h - 1);
write_cmd(0x2C);
uint8_t hi = rgb565 >> 8, lo = rgb565 & 0xFF;
for (int i = 0; i < w * 2; i += 2) { s_line_buf[i] = hi; s_line_buf[i+1] = lo; }
for (int row = 0; row < h; row++) { write_bytes(s_line_buf, (size_t)(w * 2)); }
}
/* ── Glyph rasteriser (handles scale 1..5) ── */
static void draw_char_s(int x, int y, char c, uint16_t fg, uint16_t bg, int scale)
{
if ((uint8_t)c < 32 || (uint8_t)c > 126) return;
if (scale < 1) scale = 1;
if (scale > 5) scale = 5;
const uint8_t *g = s_font[(uint8_t)c - 32];
int cw = 5 * scale, ch = 7 * scale;
uint8_t *p = s_char_buf;
for (int row = 0; row < 7; row++) {
for (int sr = 0; sr < scale; sr++) {
for (int col = 0; col < 5; col++) {
uint16_t color = ((g[col] >> row) & 1) ? fg : bg;
uint8_t hi = color >> 8, lo = color & 0xFF;
for (int sc = 0; sc < scale; sc++) { *p++ = hi; *p++ = lo; }
}
}
}
set_window(x, y, x + cw - 1, y + ch - 1);
write_cmd(0x2C);
write_bytes(s_char_buf, (size_t)(cw * ch * 2));
}
/* ── display_draw_string / display_draw_string_s ── */
void display_draw_string(int x, int y, const char *str, uint16_t fg, uint16_t bg)
{
display_draw_string_s(x, y, str, fg, bg, 1);
}
void display_draw_string_s(int x, int y, const char *str,
uint16_t fg, uint16_t bg, int scale)
{
int cx = x;
while (*str) {
draw_char_s(cx, y, *str++, fg, bg, scale);
cx += 6 * scale;
}
}
/* ── display_draw_arc ── */
void display_draw_arc(int cx, int cy, int r, int start_deg, int end_deg,
int thickness, uint16_t color)
{
for (int deg = start_deg; deg <= end_deg; deg++) {
float rad = (float)deg * (3.14159265f / 180.0f);
int px = cx + (int)((float)r * cosf(rad));
int py = cy + (int)((float)r * sinf(rad));
int half = thickness / 2;
display_fill_rect(px - half, py - half, thickness, thickness, color);
}
}

View File

@ -1,24 +0,0 @@
#pragma once
/* gc9a01.h — GC9A01 240×240 round LCD SPI driver (bd-1yr8 display bead) */
#include <stdint.h>
/* ── Initialise SPI bus + GC9A01. Call once from app_main. ── */
void gc9a01_init(void);
/* ── Display primitives (also satisfy ota_display.h contract) ── */
void display_fill_rect(int x, int y, int w, int h, uint16_t rgb565);
void display_draw_string(int x, int y, const char *str, uint16_t fg, uint16_t bg);
void display_draw_string_s(int x, int y, const char *str,
uint16_t fg, uint16_t bg, int scale);
void display_draw_arc(int cx, int cy, int r,
int start_deg, int end_deg, int thickness, uint16_t color);
/* ── Colour palette (RGB565) ── */
#define COL_BG 0x0000u
#define COL_WHITE 0xFFFFu
#define COL_GREEN 0x07E0u
#define COL_YELLOW 0xFFE0u
#define COL_RED 0xF800u
#define COL_BLUE 0x001Fu
#define COL_ORANGE 0xFD20u

View File

@ -1,285 +0,0 @@
/* gitea_ota.c — Gitea version checker (bd-3hte)
*
* Uses esp_http_client + cJSON to query:
* GET /api/v1/repos/{repo}/releases?limit=10
* Filters releases by tag prefix, extracts version and download URLs.
*/
#include "gitea_ota.h"
#include "version.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_http_client.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "cJSON.h"
#include <string.h>
#include <stdio.h>
static const char *TAG = "gitea_ota";
ota_update_info_t g_balance_update = {0};
ota_update_info_t g_io_update = {0};
/* ── WiFi connection ── */
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
#define WIFI_MAX_RETRIES 5
/* Compile-time WiFi fallback (override in NVS "wifi"/"ssid","pass") */
#define DEFAULT_WIFI_SSID "saltylab"
#define DEFAULT_WIFI_PASS ""
static EventGroupHandle_t s_wifi_eg;
static int s_wifi_retries = 0;
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_wifi_retries < WIFI_MAX_RETRIES) {
esp_wifi_connect();
s_wifi_retries++;
} else {
xEventGroupSetBits(s_wifi_eg, WIFI_FAIL_BIT);
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
s_wifi_retries = 0;
xEventGroupSetBits(s_wifi_eg, WIFI_CONNECTED_BIT);
}
}
static bool wifi_connect(void)
{
char ssid[64] = DEFAULT_WIFI_SSID;
char pass[64] = DEFAULT_WIFI_PASS;
/* Try to read credentials from NVS */
nvs_handle_t nvs;
if (nvs_open("wifi", NVS_READONLY, &nvs) == ESP_OK) {
size_t sz = sizeof(ssid);
nvs_get_str(nvs, "ssid", ssid, &sz);
sz = sizeof(pass);
nvs_get_str(nvs, "pass", pass, &sz);
nvs_close(nvs);
}
s_wifi_eg = xEventGroupCreate();
s_wifi_retries = 0;
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t h1, h2;
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, &h1));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, &h2));
wifi_config_t wcfg = {0};
strlcpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid));
strlcpy((char *)wcfg.sta.password, pass, sizeof(wcfg.sta.password));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
ESP_ERROR_CHECK(esp_wifi_start());
EventBits_t bits = xEventGroupWaitBits(s_wifi_eg,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE,
pdMS_TO_TICKS(15000));
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, h2);
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, h1);
vEventGroupDelete(s_wifi_eg);
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "WiFi connected SSID=%s", ssid);
return true;
}
ESP_LOGW(TAG, "WiFi connect failed SSID=%s", ssid);
return false;
}
/* ── HTTP fetch into a heap buffer ── */
#define HTTP_RESP_MAX (8 * 1024)
typedef struct { char *buf; int len; int cap; } http_buf_t;
static esp_err_t http_event_cb(esp_http_client_event_t *evt)
{
http_buf_t *b = (http_buf_t *)evt->user_data;
if (evt->event_id == HTTP_EVENT_ON_DATA && b) {
if (b->len + evt->data_len < b->cap) {
memcpy(b->buf + b->len, evt->data, evt->data_len);
b->len += evt->data_len;
}
}
return ESP_OK;
}
static char *http_get(const char *url)
{
char *buf = malloc(HTTP_RESP_MAX);
if (!buf) return NULL;
http_buf_t b = {.buf = buf, .len = 0, .cap = HTTP_RESP_MAX};
buf[0] = '\0';
esp_http_client_config_t cfg = {
.url = url,
.event_handler = http_event_cb,
.user_data = &b,
.timeout_ms = GITEA_API_TIMEOUT_MS,
.skip_cert_common_name_check = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
if (err != ESP_OK || status != 200) {
ESP_LOGW(TAG, "HTTP GET %s → err=%d status=%d", url, err, status);
free(buf);
return NULL;
}
buf[b.len] = '\0';
return buf;
}
/* ── Version comparison: returns true if remote > local ── */
static bool version_newer(const char *local, const char *remote)
{
int la=0,lb=0,lc=0, ra=0,rb=0,rc=0;
sscanf(local, "%d.%d.%d", &la, &lb, &lc);
sscanf(remote, "%d.%d.%d", &ra, &rb, &rc);
if (ra != la) return ra > la;
if (rb != lb) return rb > lb;
return rc > lc;
}
/* ── Parse releases JSON array, fill ota_update_info_t ── */
static void parse_releases(const char *json, const char *tag_prefix,
const char *bin_asset, const char *sha_asset,
const char *local_version,
ota_update_info_t *out)
{
cJSON *arr = cJSON_Parse(json);
if (!arr || !cJSON_IsArray(arr)) {
ESP_LOGW(TAG, "JSON parse failed");
cJSON_Delete(arr);
return;
}
cJSON *rel;
cJSON_ArrayForEach(rel, arr) {
cJSON *tag_j = cJSON_GetObjectItem(rel, "tag_name");
if (!cJSON_IsString(tag_j)) continue;
const char *tag = tag_j->valuestring;
if (strncmp(tag, tag_prefix, strlen(tag_prefix)) != 0) continue;
/* Extract version after prefix */
const char *ver = tag + strlen(tag_prefix);
if (*ver == 'v') ver++; /* strip leading 'v' */
if (!version_newer(local_version, ver)) continue;
/* Found a newer release — extract asset URLs */
cJSON *assets = cJSON_GetObjectItem(rel, "assets");
if (!cJSON_IsArray(assets)) continue;
out->available = false;
out->download_url[0] = '\0';
out->sha256[0] = '\0';
strlcpy(out->version, ver, sizeof(out->version));
cJSON *asset;
cJSON_ArrayForEach(asset, assets) {
cJSON *name_j = cJSON_GetObjectItem(asset, "name");
cJSON *url_j = cJSON_GetObjectItem(asset, "browser_download_url");
if (!cJSON_IsString(name_j) || !cJSON_IsString(url_j)) continue;
if (strcmp(name_j->valuestring, bin_asset) == 0) {
strlcpy(out->download_url, url_j->valuestring,
sizeof(out->download_url));
out->available = true;
} else if (strcmp(name_j->valuestring, sha_asset) == 0) {
/* Download the SHA256 asset inline */
char *sha = http_get(url_j->valuestring);
if (sha) {
/* sha file is just hex+newline */
size_t n = strspn(sha, "0123456789abcdefABCDEF");
if (n == 64) {
memcpy(out->sha256, sha, 64);
out->sha256[64] = '\0';
}
free(sha);
}
}
}
if (out->available) {
ESP_LOGI(TAG, "update: tag=%s ver=%s", tag, out->version);
}
break; /* use first matching release */
}
cJSON_Delete(arr);
}
/* ── Main check ── */
void gitea_ota_check_now(void)
{
char url[512];
snprintf(url, sizeof(url),
"%s/api/v1/repos/%s/releases?limit=10",
GITEA_BASE_URL, GITEA_REPO);
char *json = http_get(url);
if (!json) {
ESP_LOGW(TAG, "releases fetch failed");
return;
}
parse_releases(json, BALANCE_TAG_PREFIX, BALANCE_BIN_ASSET,
BALANCE_SHA256_ASSET, BALANCE_FW_VERSION, &g_balance_update);
parse_releases(json, IO_TAG_PREFIX, IO_BIN_ASSET,
IO_SHA256_ASSET, IO_FW_VERSION, &g_io_update);
free(json);
}
/* ── Background task ── */
static void version_check_task(void *arg)
{
/* Initial check immediately after WiFi up */
vTaskDelay(pdMS_TO_TICKS(2000));
gitea_ota_check_now();
for (;;) {
vTaskDelay(pdMS_TO_TICKS(VERSION_CHECK_PERIOD_MS));
gitea_ota_check_now();
}
}
void gitea_ota_init(void)
{
ESP_ERROR_CHECK(nvs_flash_init());
if (!wifi_connect()) {
ESP_LOGW(TAG, "WiFi unavailable — version checks disabled");
return;
}
xTaskCreate(version_check_task, "ver_check", 6144, NULL, 3, NULL);
ESP_LOGI(TAG, "version check task started");
}

View File

@ -1,42 +0,0 @@
#pragma once
/* gitea_ota.h — Gitea release version checker (bd-3hte)
*
* WiFi task: on boot and every 30 min, queries Gitea releases API,
* compares tag version against embedded FW_VERSION, stores update info.
*
* WiFi credentials read from NVS namespace "wifi" keys "ssid"/"pass".
* Fall back to compile-time defaults if NVS is empty.
*/
#include <stdint.h>
#include <stdbool.h>
/* Gitea instance */
#define GITEA_BASE_URL "http://gitea.vayrette.com"
#define GITEA_REPO "seb/saltylab-firmware"
#define GITEA_API_TIMEOUT_MS 10000
/* Version check interval */
#define VERSION_CHECK_PERIOD_MS (30u * 60u * 1000u) /* 30 minutes */
/* Max URL/version string lengths */
#define OTA_URL_MAX 384
#define OTA_VER_MAX 32
#define OTA_SHA256_MAX 65
typedef struct {
bool available;
char version[OTA_VER_MAX]; /* remote version string, e.g. "1.2.3" */
char download_url[OTA_URL_MAX]; /* direct download URL for .bin */
char sha256[OTA_SHA256_MAX]; /* hex SHA256 (from .sha256 asset), or "" */
} ota_update_info_t;
/* Shared state — written by gitea_ota_check_task, read by display/OTA tasks */
extern ota_update_info_t g_balance_update;
extern ota_update_info_t g_io_update;
/* Initialize WiFi and start version check task */
void gitea_ota_init(void);
/* One-shot sync check (can be called from any task) */
void gitea_ota_check_now(void);

View File

@ -1,5 +0,0 @@
dependencies:
idf:
version: '>=5.0'
espressif/cjson:
version: "^1.7.19~2"

View File

@ -1,110 +0,0 @@
/* main.c — ESP32-S3 BALANCE app_main (bd-66hx + OTA beads) */
#include "orin_serial.h"
#include "vesc_can.h"
#include "gc9a01.h"
#include "gitea_ota.h"
#include "ota_self.h"
#include "uart_ota.h"
#include "ota_display.h"
#include "config.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <string.h>
static const char *TAG = "main";
static QueueHandle_t s_orin_tx_q;
/* ── Telemetry task: sends TELEM_STATUS to Orin at 10 Hz ── */
static void telem_task(void *arg)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(TELEM_STATUS_PERIOD_MS));
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
bool hb_timeout = (now_ms - g_orin_ctrl.hb_last_ms) > HB_TIMEOUT_MS;
/* Determine balance state for telemetry */
bal_state_t state;
if (g_orin_ctrl.estop) {
state = BAL_ESTOP;
} else if (!g_orin_ctrl.armed) {
state = BAL_DISARMED;
} else {
state = BAL_ARMED;
}
/* flags: bit0=estop_active, bit1=heartbeat_timeout */
uint8_t flags = (g_orin_ctrl.estop ? 0x01u : 0x00u) |
(hb_timeout ? 0x02u : 0x00u);
/* Battery voltage from VESC_ID_A STATUS_5 (V×10 → mV) */
uint16_t vbat_mv = (uint16_t)((int32_t)g_vesc[0].voltage_x10 * 100);
orin_send_status(s_orin_tx_q,
0, /* pitch_x10: stub — full IMU in future bead */
0, /* motor_cmd: stub */
vbat_mv,
state,
flags);
}
}
/* ── Drive task: applies Orin drive commands to VESCs @ 50 Hz ── */
static void drive_task(void *arg)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(20)); /* 50 Hz */
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
bool hb_timeout = (now_ms - g_orin_ctrl.hb_last_ms) > HB_TIMEOUT_MS;
bool drive_stale = (now_ms - g_orin_drive.updated_ms) > DRIVE_TIMEOUT_MS;
int32_t front_erpm = 0;
if (g_orin_ctrl.armed && !g_orin_ctrl.estop &&
!hb_timeout && !drive_stale) {
front_erpm = (int32_t)g_orin_drive.speed * RPM_PER_SPEED_UNIT;
}
vesc_can_send_rpm(VESC_ID_A, front_erpm);
}
}
void app_main(void)
{
ESP_LOGI(TAG, "ESP32-S3 BALANCE starting");
/* OTA rollback health check — must be called within OTA_ROLLBACK_WINDOW_S */
ota_self_health_check();
/* Init peripherals — gc9a01 before vesc_can so BL/GPIO2 is high before TWAI takes it */
gc9a01_init();
orin_serial_init();
vesc_can_init();
/* TX queue for outbound serial frames */
s_orin_tx_q = xQueueCreate(ORIN_TX_QUEUE_DEPTH, sizeof(orin_tx_frame_t));
configASSERT(s_orin_tx_q);
/* Seed heartbeat timer so we don't immediately timeout */
g_orin_ctrl.hb_last_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
/* Create tasks */
xTaskCreate(orin_serial_rx_task, "orin_rx", 4096, s_orin_tx_q, 10, NULL);
xTaskCreate(orin_serial_tx_task, "orin_tx", 2048, s_orin_tx_q, 9, NULL);
xTaskCreate(vesc_can_rx_task, "vesc_rx", 4096, s_orin_tx_q, 10, NULL);
xTaskCreate(telem_task, "telem", 2048, NULL, 5, NULL);
xTaskCreate(drive_task, "drive", 2048, NULL, 8, NULL);
/* OTA subsystem — WiFi version checker + display overlay */
gitea_ota_init();
ota_display_init();
ESP_LOGI(TAG, "all tasks started");
/* app_main returns — FreeRTOS scheduler continues */
}

View File

@ -1,334 +0,0 @@
/* orin_serial.c — Orin↔ESP32-S3 serial protocol (bd-66hx + bd-1s1s OTA cmds) */
#include "orin_serial.h"
#include "config.h"
#include "gitea_ota.h"
#include "ota_self.h"
#include "uart_ota.h"
#include "version.h"
#include "driver/uart.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include <string.h>
#include <stdio.h>
static const char *TAG = "orin";
/* ── Shared state ── */
orin_drive_t g_orin_drive = {0};
orin_control_t g_orin_ctrl = {.armed = false, .estop = false, .hb_last_ms = 0};
/* ── CRC8-SMBUS (poly=0x07, init=0x00) ── */
static uint8_t crc8(const uint8_t *data, uint8_t len)
{
uint8_t crc = 0x00u;
for (uint8_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t b = 0; b < 8u; b++) {
crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u) : (uint8_t)(crc << 1u);
}
}
return crc;
}
/* ── Frame builder ── */
static void build_frame(orin_tx_frame_t *f, uint8_t out[/* ORIN_MAX_PAYLOAD + 4 */], uint8_t *out_len)
{
/* [SYNC][LEN][TYPE][PAYLOAD...][CRC] */
uint8_t crc_buf[2u + ORIN_MAX_PAYLOAD];
crc_buf[0] = f->len;
crc_buf[1] = f->type;
memcpy(&crc_buf[2], f->payload, f->len);
uint8_t crc = crc8(crc_buf, (uint8_t)(2u + f->len));
out[0] = ORIN_SYNC;
out[1] = f->len;
out[2] = f->type;
memcpy(&out[3], f->payload, f->len);
out[3u + f->len] = crc;
*out_len = (uint8_t)(4u + f->len);
}
/* ── Enqueue helpers ── */
static void enqueue(QueueHandle_t q, uint8_t type, const uint8_t *payload, uint8_t len)
{
orin_tx_frame_t f = {.type = type, .len = len};
if (len > 0u && payload) {
memcpy(f.payload, payload, len);
}
if (xQueueSend(q, &f, 0) != pdTRUE) {
ESP_LOGW(TAG, "tx queue full, dropped type=0x%02x", type);
}
}
void orin_send_ack(QueueHandle_t q, uint8_t cmd_type)
{
enqueue(q, RESP_ACK, &cmd_type, 1u);
}
void orin_send_nack(QueueHandle_t q, uint8_t cmd_type, uint8_t err)
{
uint8_t p[2] = {cmd_type, err};
enqueue(q, RESP_NACK, p, 2u);
}
void orin_send_status(QueueHandle_t q,
int16_t pitch_x10, int16_t motor_cmd,
uint16_t vbat_mv, bal_state_t state, uint8_t flags)
{
/* int16 pitch_x10, int16 motor_cmd, uint16 vbat_mv, uint8 state, uint8 flags — BE */
uint8_t p[8];
p[0] = (uint8_t)((uint16_t)pitch_x10 >> 8u);
p[1] = (uint8_t)((uint16_t)pitch_x10);
p[2] = (uint8_t)((uint16_t)motor_cmd >> 8u);
p[3] = (uint8_t)((uint16_t)motor_cmd);
p[4] = (uint8_t)(vbat_mv >> 8u);
p[5] = (uint8_t)(vbat_mv);
p[6] = (uint8_t)state;
p[7] = flags;
enqueue(q, TELEM_STATUS, p, 8u);
}
void orin_send_vesc(QueueHandle_t q, uint8_t telem_type,
int32_t erpm, uint16_t voltage_mv,
int16_t current_ma, uint16_t temp_c_x10)
{
/* int32 erpm, uint16 voltage_mv, int16 current_ma, uint16 temp_c_x10 — BE */
uint8_t p[10];
uint32_t u = (uint32_t)erpm;
p[0] = (uint8_t)(u >> 24u);
p[1] = (uint8_t)(u >> 16u);
p[2] = (uint8_t)(u >> 8u);
p[3] = (uint8_t)(u);
p[4] = (uint8_t)(voltage_mv >> 8u);
p[5] = (uint8_t)(voltage_mv);
p[6] = (uint8_t)((uint16_t)current_ma >> 8u);
p[7] = (uint8_t)((uint16_t)current_ma);
p[8] = (uint8_t)(temp_c_x10 >> 8u);
p[9] = (uint8_t)(temp_c_x10);
enqueue(q, telem_type, p, 10u);
}
/* ── UART init ── */
void orin_serial_init(void)
{
uart_config_t cfg = {
.baud_rate = ORIN_UART_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
};
ESP_ERROR_CHECK(uart_param_config(ORIN_UART_PORT, &cfg));
ESP_ERROR_CHECK(uart_set_pin(ORIN_UART_PORT,
ORIN_UART_TX_GPIO, ORIN_UART_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
ESP_ERROR_CHECK(uart_driver_install(ORIN_UART_PORT, ORIN_UART_RX_BUF, 0,
0, NULL, 0));
ESP_LOGI(TAG, "UART%d init OK: tx=%d rx=%d baud=%d",
ORIN_UART_PORT, ORIN_UART_TX_GPIO, ORIN_UART_RX_GPIO, ORIN_UART_BAUD);
}
/* ── RX parser state machine ── */
typedef enum {
WAIT_SYNC,
WAIT_LEN,
WAIT_TYPE,
WAIT_PAYLOAD,
WAIT_CRC,
} rx_state_t;
static void dispatch_cmd(uint8_t type, const uint8_t *payload, uint8_t len,
QueueHandle_t tx_q)
{
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
switch (type) {
case CMD_HEARTBEAT:
g_orin_ctrl.hb_last_ms = now_ms;
orin_send_ack(tx_q, type);
break;
case CMD_DRIVE:
if (len < 4u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; }
if (g_orin_ctrl.estop) { orin_send_nack(tx_q, type, ERR_ESTOP_ACTIVE); break; }
if (!g_orin_ctrl.armed) { orin_send_nack(tx_q, type, ERR_DISARMED); break; }
g_orin_drive.speed = (int16_t)(((uint16_t)payload[0] << 8u) | payload[1]);
g_orin_drive.steer = (int16_t)(((uint16_t)payload[2] << 8u) | payload[3]);
g_orin_drive.updated_ms = now_ms;
g_orin_ctrl.hb_last_ms = now_ms; /* drive counts as heartbeat */
orin_send_ack(tx_q, type);
break;
case CMD_ESTOP:
if (len < 1u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; }
g_orin_ctrl.estop = (payload[0] != 0u);
if (g_orin_ctrl.estop) {
g_orin_drive.speed = 0;
g_orin_drive.steer = 0;
}
orin_send_ack(tx_q, type);
break;
case CMD_ARM:
if (len < 1u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; }
if (g_orin_ctrl.estop && payload[0] != 0u) {
/* cannot arm while estop is active */
orin_send_nack(tx_q, type, ERR_ESTOP_ACTIVE);
break;
}
g_orin_ctrl.armed = (payload[0] != 0u);
if (!g_orin_ctrl.armed) {
g_orin_drive.speed = 0;
g_orin_drive.steer = 0;
}
orin_send_ack(tx_q, type);
break;
case CMD_OTA_CHECK:
/* Trigger an immediate Gitea version check */
gitea_ota_check_now();
orin_send_version_info(tx_q, OTA_TARGET_BALANCE,
BALANCE_FW_VERSION,
g_balance_update.available
? g_balance_update.version : "");
orin_send_version_info(tx_q, OTA_TARGET_IO,
IO_FW_VERSION,
g_io_update.available
? g_io_update.version : "");
orin_send_ack(tx_q, type);
break;
case CMD_OTA_UPDATE:
if (len < 1u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; }
{
uint8_t target = payload[0];
bool triggered = false;
if (target == OTA_TARGET_IO || target == OTA_TARGET_BOTH) {
if (!uart_ota_trigger()) {
orin_send_nack(tx_q, type,
g_io_update.available ? ERR_OTA_BUSY : ERR_OTA_NO_UPDATE);
break;
}
triggered = true;
}
if (target == OTA_TARGET_BALANCE || target == OTA_TARGET_BOTH) {
if (!ota_self_trigger()) {
if (!triggered) {
orin_send_nack(tx_q, type,
g_balance_update.available ? ERR_OTA_BUSY : ERR_OTA_NO_UPDATE);
break;
}
}
}
orin_send_ack(tx_q, type);
}
break;
default:
ESP_LOGW(TAG, "unknown cmd type=0x%02x", type);
break;
}
}
void orin_serial_rx_task(void *arg)
{
QueueHandle_t tx_q = (QueueHandle_t)arg;
rx_state_t state = WAIT_SYNC;
uint8_t rx_len = 0;
uint8_t rx_type = 0;
uint8_t payload[ORIN_MAX_PAYLOAD];
uint8_t pay_idx = 0;
uint8_t byte;
for (;;) {
int r = uart_read_bytes(ORIN_UART_PORT, &byte, 1, pdMS_TO_TICKS(10));
if (r <= 0) {
continue;
}
switch (state) {
case WAIT_SYNC:
if (byte == ORIN_SYNC) { state = WAIT_LEN; }
break;
case WAIT_LEN:
if (byte > ORIN_MAX_PAYLOAD) {
/* oversize — send NACK and reset */
orin_send_nack(tx_q, 0x00u, ERR_BAD_LEN);
state = WAIT_SYNC;
} else {
rx_len = byte;
state = WAIT_TYPE;
}
break;
case WAIT_TYPE:
rx_type = byte;
pay_idx = 0u;
state = (rx_len == 0u) ? WAIT_CRC : WAIT_PAYLOAD;
break;
case WAIT_PAYLOAD:
payload[pay_idx++] = byte;
if (pay_idx == rx_len) { state = WAIT_CRC; }
break;
case WAIT_CRC: {
/* Verify CRC over [LEN, TYPE, PAYLOAD] */
uint8_t crc_buf[2u + ORIN_MAX_PAYLOAD];
crc_buf[0] = rx_len;
crc_buf[1] = rx_type;
memcpy(&crc_buf[2], payload, rx_len);
uint8_t expected = crc8(crc_buf, (uint8_t)(2u + rx_len));
if (byte != expected) {
ESP_LOGW(TAG, "CRC fail type=0x%02x got=0x%02x exp=0x%02x",
rx_type, byte, expected);
orin_send_nack(tx_q, rx_type, ERR_BAD_CRC);
} else {
dispatch_cmd(rx_type, payload, rx_len, tx_q);
}
state = WAIT_SYNC;
break;
}
}
}
}
void orin_serial_tx_task(void *arg)
{
QueueHandle_t tx_q = (QueueHandle_t)arg;
orin_tx_frame_t f;
uint8_t wire[4u + ORIN_MAX_PAYLOAD];
uint8_t wire_len;
for (;;) {
if (xQueueReceive(tx_q, &f, portMAX_DELAY) == pdTRUE) {
build_frame(&f, wire, &wire_len);
uart_write_bytes(ORIN_UART_PORT, (const char *)wire, wire_len);
}
}
}
/* ── OTA telemetry helpers (bd-1s1s) ── */
void orin_send_ota_status(QueueHandle_t q, uint8_t target,
uint8_t state, uint8_t progress, uint8_t err)
{
/* TELEM_OTA_STATUS: uint8 target, uint8 state, uint8 progress, uint8 err */
uint8_t p[4] = {target, state, progress, err};
enqueue(q, TELEM_OTA_STATUS, p, 4u);
}
void orin_send_version_info(QueueHandle_t q, uint8_t target,
const char *current, const char *available)
{
/* TELEM_VERSION_INFO: uint8 target, char current[16], char available[16] */
uint8_t p[33];
p[0] = target;
strncpy((char *)&p[1], current, 16); p[16] = '\0';
strncpy((char *)&p[17], available ? available : "", 16); p[32] = '\0';
enqueue(q, TELEM_VERSION_INFO, p, 33u);
}

View File

@ -1,105 +0,0 @@
#pragma once
/* orin_serial.h — Orin↔ESP32-S3 BALANCE USB/UART serial protocol (bd-66hx)
*
* Frame layout (matches bd-wim1 esp32_balance_protocol.py exactly):
* [0xAA][LEN][TYPE][PAYLOAD × LEN bytes][CRC8-SMBUS]
* CRC covers LEN + TYPE + PAYLOAD bytes.
* All multi-byte payload fields are big-endian.
*
* Physical: UART0 CH343 USB-serial Orin /dev/esp32-balance @ 460800 baud
*/
#include <stdint.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
/* ── Frame constants ── */
#define ORIN_SYNC 0xAAu
#define ORIN_MAX_PAYLOAD 62u
/* ── Command types: Orin → ESP32 ── */
#define CMD_HEARTBEAT 0x01u
#define CMD_DRIVE 0x02u /* int16 speed + int16 steer, BE */
#define CMD_ESTOP 0x03u /* uint8: 1=assert, 0=clear */
#define CMD_ARM 0x04u /* uint8: 1=arm, 0=disarm */
/* ── Telemetry types: ESP32 → Orin ── */
#define TELEM_STATUS 0x80u /* status @ 10 Hz */
#define TELEM_VESC_LEFT 0x81u /* VESC ID 61 (front) telemetry @ 10 Hz */
#define TELEM_VESC_RIGHT 0x82u /* VESC ID 79 (rear) telemetry @ 10 Hz */
#define TELEM_OTA_STATUS 0x83u /* OTA state + progress (bd-1s1s) */
#define TELEM_VERSION_INFO 0x84u /* firmware version report (bd-1s1s) */
#define RESP_ACK 0xA0u
#define RESP_NACK 0xA1u
/* ── OTA commands (Orin → ESP32, bd-1s1s) ── */
#define CMD_OTA_CHECK 0x10u /* no payload: trigger Gitea version check */
#define CMD_OTA_UPDATE 0x11u /* uint8 target: 0=balance, 1=io, 2=both */
/* ── OTA target constants ── */
#define OTA_TARGET_BALANCE 0x00u
#define OTA_TARGET_IO 0x01u
#define OTA_TARGET_BOTH 0x02u
/* ── NACK error codes ── */
#define ERR_BAD_CRC 0x01u
#define ERR_BAD_LEN 0x02u
#define ERR_ESTOP_ACTIVE 0x03u
#define ERR_DISARMED 0x04u
#define ERR_OTA_BUSY 0x05u
#define ERR_OTA_NO_UPDATE 0x06u
/* ── Drive state (mirrored from TELEM_STATUS.balance_state) ── */
typedef enum {
BAL_DISARMED = 0,
BAL_ARMED = 1,
BAL_ESTOP = 3,
} bal_state_t;
/* ── Shared state written by RX task, consumed by main/vesc tasks ── */
typedef struct {
volatile int16_t speed; /* -1000..+1000 */
volatile int16_t steer; /* -1000..+1000 */
volatile uint32_t updated_ms; /* esp_timer tick at last CMD_DRIVE */
} orin_drive_t;
typedef struct {
volatile bool armed;
volatile bool estop;
volatile uint32_t hb_last_ms; /* esp_timer tick at last CMD_HEARTBEAT/CMD_DRIVE */
} orin_control_t;
/* ── TX frame queue item ── */
typedef struct {
uint8_t type;
uint8_t len;
uint8_t payload[ORIN_MAX_PAYLOAD];
} orin_tx_frame_t;
/* ── Globals (defined in orin_serial.c, extern here) ── */
extern orin_drive_t g_orin_drive;
extern orin_control_t g_orin_ctrl;
/* ── API ── */
void orin_serial_init(void);
/* Tasks — pass tx_queue as arg to both */
void orin_serial_rx_task(void *arg); /* arg = QueueHandle_t tx_queue */
void orin_serial_tx_task(void *arg); /* arg = QueueHandle_t tx_queue */
/* Enqueue outbound frames */
void orin_send_status(QueueHandle_t q,
int16_t pitch_x10, int16_t motor_cmd,
uint16_t vbat_mv, bal_state_t state, uint8_t flags);
void orin_send_vesc(QueueHandle_t q, uint8_t telem_type,
int32_t erpm, uint16_t voltage_mv,
int16_t current_ma, uint16_t temp_c_x10);
void orin_send_ack(QueueHandle_t q, uint8_t cmd_type);
void orin_send_nack(QueueHandle_t q, uint8_t cmd_type, uint8_t err);
/* OTA telemetry helpers (bd-1s1s) */
void orin_send_ota_status(QueueHandle_t q, uint8_t target,
uint8_t state, uint8_t progress, uint8_t err);
void orin_send_version_info(QueueHandle_t q, uint8_t target,
const char *current, const char *available);

View File

@ -1,150 +0,0 @@
/* ota_display.c — OTA notification/progress UI on GC9A01 (bd-1yr8)
*
* Renders OTA state overlaid on the 240×240 round HUD display:
* - BADGE: small dot on top-right when update available (idle state)
* - UPDATE SCREEN: version compare, Update Balance / Update IO / Update All
* - PROGRESS: arc around display perimeter + % + status text
* - ERROR: red banner + "RETRY" prompt
*
* The display_draw_* primitives must be provided by the GC9A01 driver.
* Actual SPI driver implementation is in a separate driver bead.
*/
#include "ota_display.h"
#include "gitea_ota.h"
#include "version.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>
#include <string.h>
static const char *TAG = "ota_disp";
/* Display centre and radius for the 240×240 GC9A01 */
#define CX 120
#define CY 120
#define RAD 110
/* ── Availability badge: 8×8 dot at top-right of display ── */
static void draw_badge(bool balance_avail, bool io_avail)
{
uint16_t col = (balance_avail || io_avail) ? COL_ORANGE : COL_BG;
display_fill_rect(200, 15, 12, 12, col);
}
/* ── Progress arc: sweeps 0→360° proportional to progress% ── */
static void draw_progress_arc(uint8_t pct, uint16_t color)
{
int end_deg = (int)(360 * pct / 100);
display_draw_arc(CX, CY, RAD, 0, end_deg, 6, color);
}
/* ── Status banner: 2 lines of text centred on display ── */
static void draw_status(const char *line1, const char *line2,
uint16_t fg, uint16_t bg)
{
display_fill_rect(20, 90, 200, 60, bg);
if (line1 && line1[0])
display_draw_string(CX - (int)(strlen(line1) * 6 / 2), 96,
line1, fg, bg);
if (line2 && line2[0])
display_draw_string(CX - (int)(strlen(line2) * 6 / 2), 116,
line2, fg, bg);
}
/* ── Main render logic ── */
void ota_display_update(void)
{
/* Determine dominant OTA state */
ota_self_state_t self = g_ota_self_state;
uart_ota_send_state_t io_s = g_uart_ota_state;
switch (self) {
case OTA_SELF_DOWNLOADING:
case OTA_SELF_VERIFYING:
case OTA_SELF_APPLYING: {
/* Balance self-update in progress */
char pct_str[16];
snprintf(pct_str, sizeof(pct_str), "%d%%", g_ota_self_progress);
const char *phase = (self == OTA_SELF_VERIFYING) ? "Verifying..." :
(self == OTA_SELF_APPLYING) ? "Applying..." :
"Downloading...";
draw_progress_arc(g_ota_self_progress, COL_BLUE);
draw_status("Updating Balance", pct_str, COL_WHITE, COL_BG);
ESP_LOGD(TAG, "balance OTA %s %d%%", phase, g_ota_self_progress);
return;
}
case OTA_SELF_REBOOTING:
draw_status("Update complete", "Rebooting...", COL_GREEN, COL_BG);
return;
case OTA_SELF_FAILED:
draw_progress_arc(0, COL_RED);
draw_status("Balance update", "FAILED RETRY?", COL_RED, COL_BG);
return;
default:
break;
}
switch (io_s) {
case UART_OTA_S_DOWNLOADING:
draw_progress_arc(g_uart_ota_progress, COL_YELLOW);
draw_status("Downloading IO", "firmware...", COL_WHITE, COL_BG);
return;
case UART_OTA_S_SENDING: {
char pct_str[16];
snprintf(pct_str, sizeof(pct_str), "%d%%", g_uart_ota_progress);
draw_progress_arc(g_uart_ota_progress, COL_YELLOW);
draw_status("Updating IO", pct_str, COL_WHITE, COL_BG);
return;
}
case UART_OTA_S_DONE:
draw_status("IO update done", "", COL_GREEN, COL_BG);
return;
case UART_OTA_S_FAILED:
draw_progress_arc(0, COL_RED);
draw_status("IO update", "FAILED RETRY?", COL_RED, COL_BG);
return;
default:
break;
}
/* Idle — show badge if update available */
bool bal_avail = g_balance_update.available;
bool io_avail = g_io_update.available;
draw_badge(bal_avail, io_avail);
if (bal_avail || io_avail) {
/* Show available versions on display when idle */
char verline[48];
if (bal_avail) {
snprintf(verline, sizeof(verline), "Bal v%s rdy",
g_balance_update.version);
draw_status(verline, io_avail ? "IO update rdy" : "",
COL_ORANGE, COL_BG);
} else if (io_avail) {
snprintf(verline, sizeof(verline), "IO v%s rdy",
g_io_update.version);
draw_status(verline, "", COL_ORANGE, COL_BG);
}
} else {
/* Clear OTA overlay area */
display_fill_rect(20, 90, 200, 60, COL_BG);
draw_badge(false, false);
}
}
/* ── Background display task (5 Hz) ── */
static void ota_display_task(void *arg)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(200));
ota_display_update();
}
}
void ota_display_init(void)
{
xTaskCreate(ota_display_task, "ota_disp", 2048, NULL, 3, NULL);
ESP_LOGI(TAG, "OTA display task started");
}

View File

@ -1,33 +0,0 @@
#pragma once
/* ota_display.h — OTA notification UI on GC9A01 round LCD (bd-1yr8)
*
* GC9A01 240×240 round display via SPI (IO12 CS, IO11 DC, IO10 RST, IO9 BL).
* Calls into display_draw_* primitives (provided by display driver layer).
* This module owns the "OTA notification overlay" rendered over the HUD.
*/
#include <stdint.h>
#include <stdbool.h>
#include "ota_self.h"
#include "uart_ota.h"
/* ── Display primitives API (must be provided by display driver) ── */
void display_fill_rect(int x, int y, int w, int h, uint16_t rgb565);
void display_draw_string(int x, int y, const char *str, uint16_t fg, uint16_t bg);
void display_draw_arc(int cx, int cy, int r, int start_deg, int end_deg,
int thickness, uint16_t color);
/* ── Colour palette (RGB565) ── */
#define COL_BG 0x0000u /* black */
#define COL_WHITE 0xFFFFu
#define COL_GREEN 0x07E0u
#define COL_YELLOW 0xFFE0u
#define COL_RED 0xF800u
#define COL_BLUE 0x001Fu
#define COL_ORANGE 0xFD20u
/* ── OTA display task: runs at 5 Hz, overlays OTA state on HUD ── */
void ota_display_init(void);
/* Called from main loop or display task to render the OTA overlay */
void ota_display_update(void);

View File

@ -1,183 +0,0 @@
/* ota_self.c — Balance self-OTA (bd-18nb)
*
* Uses esp_https_ota / esp_ota_ops to download from Gitea release URL,
* stream-verify SHA256 with mbedTLS, set new boot partition, and reboot.
* CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE in sdkconfig allows auto-rollback
* if the new image doesn't call esp_ota_mark_app_valid_cancel_rollback()
* within OTA_ROLLBACK_WINDOW_S seconds.
*/
#include "ota_self.h"
#include "gitea_ota.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_http_client.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mbedtls/sha256.h"
#include <string.h>
#include <stdio.h>
static const char *TAG = "ota_self";
volatile ota_self_state_t g_ota_self_state = OTA_SELF_IDLE;
volatile uint8_t g_ota_self_progress = 0;
#define OTA_CHUNK_SIZE 4096
/* ── SHA256 verify helper ── */
static bool sha256_matches(const uint8_t *digest, const char *expected_hex)
{
if (!expected_hex || expected_hex[0] == '\0') {
ESP_LOGW(TAG, "no SHA256 to verify — skipping");
return true;
}
char got[65] = {0};
for (int i = 0; i < 32; i++) {
snprintf(&got[i*2], 3, "%02x", digest[i]);
}
bool ok = (strncasecmp(got, expected_hex, 64) == 0);
if (!ok) {
ESP_LOGE(TAG, "SHA256 mismatch: got=%s exp=%s", got, expected_hex);
}
return ok;
}
/* ── OTA download + flash task ── */
static void ota_self_task(void *arg)
{
const char *url = g_balance_update.download_url;
const char *sha256 = g_balance_update.sha256;
g_ota_self_state = OTA_SELF_DOWNLOADING;
g_ota_self_progress = 0;
ESP_LOGI(TAG, "OTA start: %s", url);
esp_ota_handle_t handle = 0;
const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL);
if (!ota_part) {
ESP_LOGE(TAG, "no OTA partition");
g_ota_self_state = OTA_SELF_FAILED;
vTaskDelete(NULL);
return;
}
esp_err_t err = esp_ota_begin(ota_part, OTA_WITH_SEQUENTIAL_WRITES, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ota_begin: %s", esp_err_to_name(err));
g_ota_self_state = OTA_SELF_FAILED;
vTaskDelete(NULL);
return;
}
/* Setup HTTP client */
esp_http_client_config_t hcfg = {
.url = url,
.timeout_ms = 30000,
.buffer_size = OTA_CHUNK_SIZE,
.skip_cert_common_name_check = true,
};
esp_http_client_handle_t client = esp_http_client_init(&hcfg);
err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "http_open: %s", esp_err_to_name(err));
esp_ota_abort(handle);
esp_http_client_cleanup(client);
g_ota_self_state = OTA_SELF_FAILED;
vTaskDelete(NULL);
return;
}
int content_len = esp_http_client_fetch_headers(client);
ESP_LOGI(TAG, "content-length: %d", content_len);
mbedtls_sha256_context sha_ctx;
mbedtls_sha256_init(&sha_ctx);
mbedtls_sha256_starts(&sha_ctx, 0); /* 0 = SHA-256 */
static uint8_t buf[OTA_CHUNK_SIZE];
int total = 0;
int rd;
while ((rd = esp_http_client_read(client, (char *)buf, sizeof(buf))) > 0) {
mbedtls_sha256_update(&sha_ctx, buf, rd);
err = esp_ota_write(handle, buf, rd);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ota_write: %s", esp_err_to_name(err));
esp_ota_abort(handle);
goto cleanup;
}
total += rd;
if (content_len > 0) {
g_ota_self_progress = (uint8_t)((total * 100) / content_len);
}
}
esp_http_client_close(client);
/* Verify SHA256 */
g_ota_self_state = OTA_SELF_VERIFYING;
uint8_t digest[32];
mbedtls_sha256_finish(&sha_ctx, digest);
if (!sha256_matches(digest, sha256)) {
ESP_LOGE(TAG, "SHA256 verification failed");
esp_ota_abort(handle);
g_ota_self_state = OTA_SELF_FAILED;
goto cleanup;
}
/* Finalize + set boot partition */
g_ota_self_state = OTA_SELF_APPLYING;
err = esp_ota_end(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ota_end: %s", esp_err_to_name(err));
g_ota_self_state = OTA_SELF_FAILED;
goto cleanup;
}
err = esp_ota_set_boot_partition(ota_part);
if (err != ESP_OK) {
ESP_LOGE(TAG, "set_boot_partition: %s", esp_err_to_name(err));
g_ota_self_state = OTA_SELF_FAILED;
goto cleanup;
}
g_ota_self_state = OTA_SELF_REBOOTING;
g_ota_self_progress = 100;
ESP_LOGI(TAG, "OTA success — rebooting");
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
cleanup:
mbedtls_sha256_free(&sha_ctx);
esp_http_client_cleanup(client);
handle = 0;
vTaskDelete(NULL);
}
bool ota_self_trigger(void)
{
if (!g_balance_update.available) {
ESP_LOGW(TAG, "no update available");
return false;
}
if (g_ota_self_state != OTA_SELF_IDLE) {
ESP_LOGW(TAG, "OTA already in progress (state=%d)", g_ota_self_state);
return false;
}
xTaskCreate(ota_self_task, "ota_self", 8192, NULL, 5, NULL);
return true;
}
void ota_self_health_check(void)
{
/* Mark running image as valid — prevents rollback */
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
if (err == ESP_OK) {
ESP_LOGI(TAG, "image marked valid");
} else if (err == ESP_ERR_NOT_SUPPORTED) {
/* Not an OTA image (e.g., flashed via JTAG) — ignore */
} else {
ESP_LOGW(TAG, "mark_valid: %s", esp_err_to_name(err));
}
}

View File

@ -1,34 +0,0 @@
#pragma once
/* ota_self.h — Balance self-OTA (bd-18nb)
*
* Downloads balance-firmware.bin from Gitea release URL to the inactive
* OTA partition, verifies SHA256, sets boot partition, reboots.
* Auto-rollback if health check not called within ROLLBACK_WINDOW_S seconds.
*/
#include <stdint.h>
#include <stdbool.h>
#define OTA_ROLLBACK_WINDOW_S 30
typedef enum {
OTA_SELF_IDLE = 0,
OTA_SELF_CHECKING, /* (unused — gitea_ota handles this) */
OTA_SELF_DOWNLOADING,
OTA_SELF_VERIFYING,
OTA_SELF_APPLYING,
OTA_SELF_REBOOTING,
OTA_SELF_FAILED,
} ota_self_state_t;
extern volatile ota_self_state_t g_ota_self_state;
extern volatile uint8_t g_ota_self_progress; /* 0-100 % */
/* Trigger a Balance self-update.
* Uses g_balance_update (from gitea_ota). Non-blocking: starts in a task.
* Returns false if no update available or OTA already in progress. */
bool ota_self_trigger(void);
/* Called from app_main after boot to mark the running image as valid.
* Must be called within OTA_ROLLBACK_WINDOW_S after boot or rollback fires. */
void ota_self_health_check(void);

View File

@ -1,241 +0,0 @@
/* uart_ota.c — UART OTA sender: Balance→IO board (bd-21hv)
*
* Downloads io-firmware.bin from Gitea, then sends to IO board via UART1.
* IO board must update itself BEFORE Balance self-update (per spec).
*/
#include "uart_ota.h"
#include "gitea_ota.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mbedtls/sha256.h"
#include <string.h>
#include <stdio.h>
static const char *TAG = "uart_ota";
volatile uart_ota_send_state_t g_uart_ota_state = UART_OTA_S_IDLE;
volatile uint8_t g_uart_ota_progress = 0;
/* ── CRC8-SMBUS ── */
static uint8_t crc8(const uint8_t *d, uint16_t len)
{
uint8_t crc = 0;
for (uint16_t i = 0; i < len; i++) {
crc ^= d[i];
for (uint8_t b = 0; b < 8; b++)
crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u) : (uint8_t)(crc << 1u);
}
return crc;
}
/* ── Build and send one UART OTA frame ── */
static void send_frame(uint8_t type, uint16_t seq,
const uint8_t *payload, uint16_t plen)
{
/* [TYPE:1][SEQ:2 BE][LEN:2 BE][PAYLOAD][CRC8:1] */
uint8_t hdr[5];
hdr[0] = type;
hdr[1] = (uint8_t)(seq >> 8u);
hdr[2] = (uint8_t)(seq);
hdr[3] = (uint8_t)(plen >> 8u);
hdr[4] = (uint8_t)(plen);
/* CRC over hdr + payload */
uint8_t crc_buf[5 + OTA_UART_CHUNK_SIZE];
memcpy(crc_buf, hdr, 5);
if (plen > 0 && payload) memcpy(crc_buf + 5, payload, plen);
uint8_t crc = crc8(crc_buf, (uint16_t)(5 + plen));
uart_write_bytes(UART_OTA_PORT, (char *)hdr, 5);
if (plen > 0 && payload)
uart_write_bytes(UART_OTA_PORT, (char *)payload, plen);
uart_write_bytes(UART_OTA_PORT, (char *)&crc, 1);
}
/* ── Wait for ACK/NACK from IO board ── */
static bool wait_ack(uint16_t expected_seq)
{
/* Response frame: [TYPE:1][SEQ:2][LEN:2][PAYLOAD][CRC:1] */
uint8_t buf[16];
int timeout = OTA_UART_ACK_TIMEOUT_MS;
int got = 0;
while (timeout > 0 && got < 6) {
int r = uart_read_bytes(UART_OTA_PORT, buf + got, 1, pdMS_TO_TICKS(50));
if (r > 0) got++;
else timeout -= 50;
}
if (got < 3) return false;
uint8_t type = buf[0];
uint16_t seq = (uint16_t)((buf[1] << 8u) | buf[2]);
if (type == UART_OTA_ACK && seq == expected_seq) return true;
if (type == UART_OTA_NACK) {
uint8_t err = (got >= 6) ? buf[5] : 0;
ESP_LOGW(TAG, "NACK seq=%u err=%u", seq, err);
}
return false;
}
/* ── Download firmware to RAM buffer (max 1.75 MB) ── */
static uint8_t *download_io_firmware(uint32_t *out_size)
{
const char *url = g_io_update.download_url;
ESP_LOGI(TAG, "downloading IO fw: %s", url);
esp_http_client_config_t cfg = {
.url = url, .timeout_ms = 30000,
.skip_cert_common_name_check = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (esp_http_client_open(client, 0) != ESP_OK) {
esp_http_client_cleanup(client);
return NULL;
}
int content_len = esp_http_client_fetch_headers(client);
if (content_len <= 0 || content_len > (int)(0x1B0000)) {
ESP_LOGE(TAG, "bad content-length: %d", content_len);
esp_http_client_cleanup(client);
return NULL;
}
uint8_t *buf = malloc(content_len);
if (!buf) {
ESP_LOGE(TAG, "malloc %d failed", content_len);
esp_http_client_cleanup(client);
return NULL;
}
int total = 0, rd;
while ((rd = esp_http_client_read(client, (char *)buf + total,
content_len - total)) > 0) {
total += rd;
g_uart_ota_progress = (uint8_t)((total * 50) / content_len); /* 0-50% for download */
}
esp_http_client_cleanup(client);
if (total != content_len) {
free(buf);
return NULL;
}
*out_size = (uint32_t)total;
return buf;
}
/* ── UART OTA send task ── */
static void uart_ota_task(void *arg)
{
g_uart_ota_state = UART_OTA_S_DOWNLOADING;
g_uart_ota_progress = 0;
uint32_t fw_size = 0;
uint8_t *fw = download_io_firmware(&fw_size);
if (!fw) {
ESP_LOGE(TAG, "download failed");
g_uart_ota_state = UART_OTA_S_FAILED;
vTaskDelete(NULL);
return;
}
/* Compute SHA256 of downloaded firmware */
uint8_t digest[32];
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
mbedtls_sha256_starts(&sha, 0);
mbedtls_sha256_update(&sha, fw, fw_size);
mbedtls_sha256_finish(&sha, digest);
mbedtls_sha256_free(&sha);
g_uart_ota_state = UART_OTA_S_SENDING;
/* Send OTA_BEGIN: uint32 size + uint8[32] sha256 */
uint8_t begin_payload[36];
begin_payload[0] = (uint8_t)(fw_size >> 24u);
begin_payload[1] = (uint8_t)(fw_size >> 16u);
begin_payload[2] = (uint8_t)(fw_size >> 8u);
begin_payload[3] = (uint8_t)(fw_size);
memcpy(&begin_payload[4], digest, 32);
for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) {
send_frame(UART_OTA_BEGIN, 0, begin_payload, 36);
if (wait_ack(0)) goto send_data;
ESP_LOGW(TAG, "BEGIN retry %d", retry);
}
ESP_LOGE(TAG, "BEGIN failed");
free(fw);
g_uart_ota_state = UART_OTA_S_FAILED;
vTaskDelete(NULL);
return;
send_data: {
uint32_t offset = 0;
uint16_t seq = 1;
while (offset < fw_size) {
uint16_t chunk = (uint16_t)((fw_size - offset) < OTA_UART_CHUNK_SIZE
? (fw_size - offset) : OTA_UART_CHUNK_SIZE);
bool acked = false;
for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) {
send_frame(UART_OTA_DATA, seq, fw + offset, chunk);
if (wait_ack(seq)) { acked = true; break; }
ESP_LOGW(TAG, "DATA seq=%u retry=%d", seq, retry);
}
if (!acked) {
ESP_LOGE(TAG, "DATA seq=%u failed", seq);
send_frame(UART_OTA_ABORT, seq, NULL, 0);
free(fw);
g_uart_ota_state = UART_OTA_S_FAILED;
vTaskDelete(NULL);
return;
}
offset += chunk;
seq++;
/* 50-100% for sending phase */
g_uart_ota_progress = (uint8_t)(50u + (offset * 50u) / fw_size);
}
/* Send OTA_END */
for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) {
send_frame(UART_OTA_END, seq, NULL, 0);
if (wait_ack(seq)) break;
}
}
free(fw);
g_uart_ota_progress = 100;
g_uart_ota_state = UART_OTA_S_DONE;
ESP_LOGI(TAG, "IO OTA complete — %lu bytes sent", (unsigned long)fw_size);
vTaskDelete(NULL);
}
bool uart_ota_trigger(void)
{
if (!g_io_update.available) {
ESP_LOGW(TAG, "no IO update available");
return false;
}
if (g_uart_ota_state != UART_OTA_S_IDLE) {
ESP_LOGW(TAG, "UART OTA busy (state=%d)", g_uart_ota_state);
return false;
}
/* Init UART1 for OTA */
uart_config_t ucfg = {
.baud_rate = UART_OTA_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
};
uart_param_config(UART_OTA_PORT, &ucfg);
uart_set_pin(UART_OTA_PORT, UART_OTA_TX_GPIO, UART_OTA_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(UART_OTA_PORT, 2048, 0, 0, NULL, 0);
xTaskCreate(uart_ota_task, "uart_ota", 16384, NULL, 4, NULL);
return true;
}

View File

@ -1,64 +0,0 @@
#pragma once
/* uart_ota.h — UART OTA protocol for Balance→IO firmware update (bd-21hv)
*
* Balance downloads io-firmware.bin from Gitea, then streams it to the IO
* board over UART1 (GPIO17/18, 460800 baud) in 1 KB chunks with ACK.
*
* Protocol frame format (both directions):
* [TYPE:1][SEQ:2 BE][LEN:2 BE][PAYLOAD:LEN][CRC8:1]
* CRC8-SMBUS over TYPE+SEQ+LEN+PAYLOAD.
*
* BalanceIO:
* OTA_BEGIN (0xC0) payload: uint32 total_size BE + uint8[32] sha256
* OTA_DATA (0xC1) payload: uint8[] chunk (up to 1024 bytes)
* OTA_END (0xC2) no payload
* OTA_ABORT (0xC3) no payload
*
* IOBalance:
* OTA_ACK (0xC4) payload: uint16 acked_seq BE
* OTA_NACK (0xC5) payload: uint16 failed_seq BE + uint8 err_code
* OTA_STATUS (0xC6) payload: uint8 state + uint8 progress%
*/
#include <stdint.h>
#include <stdbool.h>
/* UART for Balance→IO OTA */
#include "driver/uart.h"
#define UART_OTA_PORT UART_NUM_1
#define UART_OTA_BAUD 460800
#define UART_OTA_TX_GPIO 17
#define UART_OTA_RX_GPIO 18
#define OTA_UART_CHUNK_SIZE 1024
#define OTA_UART_ACK_TIMEOUT_MS 3000
#define OTA_UART_MAX_RETRIES 3
/* Frame type bytes */
#define UART_OTA_BEGIN 0xC0u
#define UART_OTA_DATA 0xC1u
#define UART_OTA_END 0xC2u
#define UART_OTA_ABORT 0xC3u
#define UART_OTA_ACK 0xC4u
#define UART_OTA_NACK 0xC5u
#define UART_OTA_STATUS 0xC6u
/* NACK error codes */
#define OTA_ERR_BAD_CRC 0x01u
#define OTA_ERR_WRITE 0x02u
#define OTA_ERR_SIZE 0x03u
typedef enum {
UART_OTA_S_IDLE = 0,
UART_OTA_S_DOWNLOADING, /* downloading from Gitea */
UART_OTA_S_SENDING, /* sending to IO board */
UART_OTA_S_DONE,
UART_OTA_S_FAILED,
} uart_ota_send_state_t;
extern volatile uart_ota_send_state_t g_uart_ota_state;
extern volatile uint8_t g_uart_ota_progress;
/* Trigger IO firmware update. Uses g_io_update (from gitea_ota).
* Downloads bin, then streams via UART. Returns false if busy or no update. */
bool uart_ota_trigger(void);

View File

@ -1,14 +0,0 @@
#pragma once
/* Embedded firmware version — bump on each release */
#define BALANCE_FW_VERSION "1.0.0"
#define IO_FW_VERSION "1.0.0"
/* Gitea release tag prefixes */
#define BALANCE_TAG_PREFIX "esp32-balance/"
#define IO_TAG_PREFIX "esp32-io/"
/* Gitea release asset filenames */
#define BALANCE_BIN_ASSET "balance-firmware.bin"
#define IO_BIN_ASSET "io-firmware.bin"
#define BALANCE_SHA256_ASSET "balance-firmware.sha256"
#define IO_SHA256_ASSET "io-firmware.sha256"

View File

@ -1,119 +0,0 @@
/* vesc_can.c — VESC CAN TWAI driver (bd-66hx)
*
* Receives VESC STATUS/4/5 frames via TWAI, proxies to Orin over serial.
* Transmits SET_RPM commands from Orin drive requests.
*/
#include "vesc_can.h"
#include "orin_serial.h"
#include "config.h"
#include "driver/twai.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
static const char *TAG = "vesc_can";
vesc_state_t g_vesc[2] = {0};
/* Index for a given VESC node ID: 0=VESC_ID_A, 1=VESC_ID_B */
static int vesc_idx(uint8_t id)
{
if (id == VESC_ID_A) return 0;
if (id == VESC_ID_B) return 1;
return -1;
}
void vesc_can_init(void)
{
twai_general_config_t gcfg = TWAI_GENERAL_CONFIG_DEFAULT(
(gpio_num_t)VESC_CAN_TX_GPIO,
(gpio_num_t)VESC_CAN_RX_GPIO,
TWAI_MODE_NORMAL);
gcfg.rx_queue_len = VESC_CAN_RX_QUEUE;
twai_timing_config_t tcfg = TWAI_TIMING_CONFIG_500KBITS();
twai_filter_config_t fcfg = TWAI_FILTER_CONFIG_ACCEPT_ALL();
ESP_ERROR_CHECK(twai_driver_install(&gcfg, &tcfg, &fcfg));
ESP_ERROR_CHECK(twai_start());
ESP_LOGI(TAG, "TWAI init OK: tx=%d rx=%d 500kbps", VESC_CAN_TX_GPIO, VESC_CAN_RX_GPIO);
}
void vesc_can_send_rpm(uint8_t vesc_id, int32_t erpm)
{
uint32_t ext_id = ((uint32_t)VESC_PKT_SET_RPM << 8u) | vesc_id;
twai_message_t msg = {
.extd = 1,
.identifier = ext_id,
.data_length_code = 4,
};
uint32_t u = (uint32_t)erpm;
msg.data[0] = (uint8_t)(u >> 24u);
msg.data[1] = (uint8_t)(u >> 16u);
msg.data[2] = (uint8_t)(u >> 8u);
msg.data[3] = (uint8_t)(u);
twai_transmit(&msg, pdMS_TO_TICKS(5));
}
void vesc_can_rx_task(void *arg)
{
QueueHandle_t tx_q = (QueueHandle_t)arg;
twai_message_t msg;
for (;;) {
if (twai_receive(&msg, pdMS_TO_TICKS(50)) != ESP_OK) {
continue;
}
if (!msg.extd) {
continue; /* ignore standard frames */
}
uint8_t pkt_type = (uint8_t)(msg.identifier >> 8u);
uint8_t vesc_id = (uint8_t)(msg.identifier & 0xFFu);
int idx = vesc_idx(vesc_id);
if (idx < 0) {
continue; /* not our VESC */
}
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
vesc_state_t *s = &g_vesc[idx];
switch (pkt_type) {
case VESC_PKT_STATUS:
if (msg.data_length_code < 8u) { break; }
s->erpm = (int32_t)(
((uint32_t)msg.data[0] << 24u) | ((uint32_t)msg.data[1] << 16u) |
((uint32_t)msg.data[2] << 8u) | (uint32_t)msg.data[3]);
s->current_x10 = (int16_t)(((uint16_t)msg.data[4] << 8u) | msg.data[5]);
s->last_rx_ms = now_ms;
/* Proxy to Orin: voltage from STATUS_5 (may be zero until received) */
{
uint8_t ttype = (vesc_id == VESC_ID_A) ? TELEM_VESC_LEFT : TELEM_VESC_RIGHT;
/* voltage_mv: V×10 → mV (/10 * 1000 = *100); current_ma: A×10 → mA (*100) */
uint16_t vmv = (uint16_t)((int32_t)s->voltage_x10 * 100);
int16_t ima = (int16_t)((int32_t)s->current_x10 * 100);
orin_send_vesc(tx_q, ttype, s->erpm, vmv, ima,
(uint16_t)s->temp_mot_x10);
}
break;
case VESC_PKT_STATUS_4:
if (msg.data_length_code < 6u) { break; }
/* T_fet×10, T_mot×10, I_in×10 */
s->temp_mot_x10 = (int16_t)(((uint16_t)msg.data[2] << 8u) | msg.data[3]);
break;
case VESC_PKT_STATUS_5:
if (msg.data_length_code < 6u) { break; }
/* int32 tacho (ignored), int16 V_in×10 */
s->voltage_x10 = (int16_t)(((uint16_t)msg.data[4] << 8u) | msg.data[5]);
break;
default:
break;
}
}
}

View File

@ -1,36 +0,0 @@
#pragma once
/* vesc_can.h — VESC CAN TWAI driver for ESP32-S3 BALANCE (bd-66hx)
*
* VESC extended CAN ID: (packet_type << 8) | vesc_node_id
* Physical layer: TWAI peripheral SN65HVD230 500 kbps shared bus
*/
#include <stdint.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
/* ── VESC packet types ── */
#define VESC_PKT_SET_RPM 3u
#define VESC_PKT_STATUS 9u /* int32 erpm, int16 I×10, int16 duty×1000 */
#define VESC_PKT_STATUS_4 16u /* int16 T_fet×10, T_mot×10, I_in×10 */
#define VESC_PKT_STATUS_5 27u /* int32 tacho, int16 V_in×10 */
/* ── VESC telemetry snapshot ── */
typedef struct {
int32_t erpm; /* electrical RPM (STATUS) */
int16_t current_x10; /* phase current A×10 (STATUS) */
int16_t voltage_x10; /* bus voltage V×10 (STATUS_5) */
int16_t temp_mot_x10; /* motor temp °C×10 (STATUS_4) */
uint32_t last_rx_ms; /* esp_timer ms of last STATUS frame */
} vesc_state_t;
/* ── Globals (two VESC nodes: index 0 = VESC_ID_A=56, 1 = VESC_ID_B=68) ── */
extern vesc_state_t g_vesc[2];
/* ── API ── */
void vesc_can_init(void);
void vesc_can_send_rpm(uint8_t vesc_id, int32_t erpm);
/* RX task — pass tx_queue as arg; forwards STATUS frames to Orin over serial */
void vesc_can_rx_task(void *arg); /* arg = QueueHandle_t orin_tx_queue */

View File

@ -1,7 +0,0 @@
# ESP32-S3 BALANCE — 4 MB flash, dual OTA partitions
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x1B0000,
app1, app, ota_1, 0x1C0000, 0x1B0000,
nvs_user, data, nvs, 0x370000, 0x50000,
1 # ESP32-S3 BALANCE — 4 MB flash, dual OTA partitions
2 # Name, Type, SubType, Offset, Size
3 nvs, data, nvs, 0x9000, 0x5000,
4 otadata, data, ota, 0xe000, 0x2000,
5 app0, app, ota_0, 0x10000, 0x1B0000,
6 app1, app, ota_1, 0x1C0000, 0x1B0000,
7 nvs_user, data, nvs, 0x370000, 0x50000,

View File

@ -1,23 +0,0 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_TASK_WDT_EN=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_TWAI_ISR_IN_IRAM=y
CONFIG_UART_ISR_IN_IRAM=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_UART_NUM=0
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# OTA — bd-3gwo: dual OTA partitions + rollback
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_OTA_ALLOW_HTTP=y
CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
# bd-66hx hardware: disable brownout detection — power supply dips during SPI/init
# The gc9a01 display SPI init causes ~50mA transient that trips level-0 brownout
CONFIG_ESP_BROWNOUT_DET=n

View File

@ -1,3 +0,0 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32s3_io)

View File

@ -1,10 +0,0 @@
idf_component_register(
SRCS "main.c" "uart_ota_recv.c"
INCLUDE_DIRS "."
REQUIRES
app_update
mbedtls
driver
freertos
esp_timer
)

View File

@ -1,35 +0,0 @@
#pragma once
/* ESP32-S3 IO board — pin assignments (SAUL-TEE-SYSTEM-REFERENCE.md) */
/* ── Inter-board UART (to/from BALANCE board) ── */
#define IO_UART_PORT UART_NUM_0
#define IO_UART_BAUD 460800
#define IO_UART_TX_GPIO 43 /* IO board UART0_TXD → BALANCE RX */
#define IO_UART_RX_GPIO 44 /* IO board UART0_RXD ← BALANCE TX */
/* Note: SAUL-TEE spec says IO TX=IO18, RX=IO21; BALANCE TX=IO17, RX=IO18.
* This is UART0 on the IO devkit (GPIO43/44). Adjust to match actual wiring. */
/* ── BTS7960 Left motor driver ── */
#define MOTOR_L_RPWM 1
#define MOTOR_L_LPWM 2
#define MOTOR_L_EN_R 3
#define MOTOR_L_EN_L 4
/* ── BTS7960 Right motor driver ── */
#define MOTOR_R_RPWM 5
#define MOTOR_R_LPWM 6
#define MOTOR_R_EN_R 7
#define MOTOR_R_EN_L 8
/* ── Arming button / kill switch ── */
#define ARM_BTN_GPIO 9
#define KILL_GPIO 10
/* ── WS2812B LED strip ── */
#define LED_DATA_GPIO 13
/* ── OTA UART — receives firmware from BALANCE (bd-21hv) ── */
/* Uses same IO_UART_PORT since Balance drives OTA over the inter-board link */
/* ── Firmware version ── */
#define IO_FW_VERSION "1.0.0"

View File

@ -1,42 +0,0 @@
/* main.c — ESP32-S3 IO board app_main */
#include "uart_ota_recv.h"
#include "config.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "driver/uart.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "io_main";
static void uart_init(void)
{
uart_config_t cfg = {
.baud_rate = IO_UART_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
};
uart_param_config(IO_UART_PORT, &cfg);
uart_set_pin(IO_UART_PORT, IO_UART_TX_GPIO, IO_UART_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(IO_UART_PORT, 4096, 0, 0, NULL, 0);
}
void app_main(void)
{
ESP_LOGI(TAG, "ESP32-S3 IO v%s starting", IO_FW_VERSION);
/* Mark running image valid (OTA rollback support) */
esp_ota_mark_app_valid_cancel_rollback();
uart_init();
uart_ota_recv_init();
/* IO board main loop placeholder — RC/motor/sensor tasks added in later beads */
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

View File

@ -1,210 +0,0 @@
/* uart_ota_recv.c — IO board OTA receiver (bd-21hv)
*
* Listens on UART0 for OTA frames from Balance board.
* Writes incoming chunks to the inactive OTA partition, verifies SHA256,
* then reboots into new firmware.
*/
#include "uart_ota_recv.h"
#include "config.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "driver/uart.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mbedtls/sha256.h"
#include <string.h>
static const char *TAG = "io_ota";
volatile io_ota_state_t g_io_ota_state = IO_OTA_IDLE;
volatile uint8_t g_io_ota_progress = 0;
/* Frame type bytes (same as uart_ota.h sender side) */
#define OTA_BEGIN 0xC0u
#define OTA_DATA 0xC1u
#define OTA_END 0xC2u
#define OTA_ABORT 0xC3u
#define OTA_ACK 0xC4u
#define OTA_NACK 0xC5u
#define CHUNK_MAX 1024
static uint8_t crc8(const uint8_t *d, uint16_t len)
{
uint8_t crc = 0;
for (uint16_t i = 0; i < len; i++) {
crc ^= d[i];
for (uint8_t b = 0; b < 8; b++)
crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u) : (uint8_t)(crc << 1u);
}
return crc;
}
static void send_ack(uint16_t seq)
{
uint8_t frame[6];
frame[0] = OTA_ACK;
frame[1] = (uint8_t)(seq >> 8u);
frame[2] = (uint8_t)(seq);
frame[3] = 0; frame[4] = 0; /* LEN=0 */
uint8_t crc = crc8(frame, 5);
frame[5] = crc;
uart_write_bytes(IO_UART_PORT, (char *)frame, 6);
}
static void send_nack(uint16_t seq, uint8_t err)
{
uint8_t frame[8];
frame[0] = OTA_NACK;
frame[1] = (uint8_t)(seq >> 8u);
frame[2] = (uint8_t)(seq);
frame[3] = 0; frame[4] = 1; /* LEN=1 */
frame[5] = err;
uint8_t crc = crc8(frame, 6);
frame[6] = crc;
uart_write_bytes(IO_UART_PORT, (char *)frame, 7);
}
/* Read exact n bytes with timeout */
static bool uart_read_exact(uint8_t *buf, int n, int timeout_ms)
{
int got = 0;
while (got < n && timeout_ms > 0) {
int r = uart_read_bytes(IO_UART_PORT, buf + got, n - got,
pdMS_TO_TICKS(50));
if (r > 0) got += r;
else timeout_ms -= 50;
}
return got == n;
}
static void ota_recv_task(void *arg)
{
esp_ota_handle_t handle = 0;
const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL);
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
uint32_t expected_size = 0;
uint8_t expected_digest[32] = {0};
uint32_t received = 0;
bool ota_started = false;
static uint8_t payload[CHUNK_MAX];
for (;;) {
/* Read frame header: TYPE(1) + SEQ(2) + LEN(2) = 5 bytes */
uint8_t hdr[5];
if (!uart_read_exact(hdr, 5, 5000)) continue;
uint8_t type = hdr[0];
uint16_t seq = (uint16_t)((hdr[1] << 8u) | hdr[2]);
uint16_t plen = (uint16_t)((hdr[3] << 8u) | hdr[4]);
if (plen > CHUNK_MAX + 36) {
ESP_LOGW(TAG, "oversized frame plen=%u", plen);
continue;
}
/* Read payload + CRC */
if (plen > 0 && !uart_read_exact(payload, plen, 2000)) continue;
uint8_t crc_rx;
if (!uart_read_exact(&crc_rx, 1, 500)) continue;
/* Verify CRC over hdr+payload */
uint8_t crc_buf[5 + CHUNK_MAX + 36];
memcpy(crc_buf, hdr, 5);
if (plen > 0) memcpy(crc_buf + 5, payload, plen);
uint8_t expected_crc = crc8(crc_buf, (uint16_t)(5 + plen));
if (crc_rx != expected_crc) {
ESP_LOGW(TAG, "CRC fail seq=%u", seq);
send_nack(seq, 0x01u); /* OTA_ERR_BAD_CRC */
continue;
}
switch (type) {
case OTA_BEGIN:
if (plen < 36) { send_nack(seq, 0x03u); break; }
expected_size = ((uint32_t)payload[0] << 24u) |
((uint32_t)payload[1] << 16u) |
((uint32_t)payload[2] << 8u) |
(uint32_t)payload[3];
memcpy(expected_digest, &payload[4], 32);
if (!ota_part || esp_ota_begin(ota_part, OTA_WITH_SEQUENTIAL_WRITES,
&handle) != ESP_OK) {
send_nack(seq, 0x02u);
break;
}
mbedtls_sha256_starts(&sha, 0);
received = 0;
ota_started = true;
g_io_ota_state = IO_OTA_RECEIVING;
g_io_ota_progress = 0;
ESP_LOGI(TAG, "OTA begin: %lu bytes", (unsigned long)expected_size);
send_ack(seq);
break;
case OTA_DATA:
if (!ota_started) { send_nack(seq, 0x02u); break; }
if (esp_ota_write(handle, payload, plen) != ESP_OK) {
send_nack(seq, 0x02u);
esp_ota_abort(handle);
ota_started = false;
g_io_ota_state = IO_OTA_FAILED;
break;
}
mbedtls_sha256_update(&sha, payload, plen);
received += plen;
if (expected_size > 0)
g_io_ota_progress = (uint8_t)((received * 100u) / expected_size);
send_ack(seq);
break;
case OTA_END: {
if (!ota_started) { send_nack(seq, 0x02u); break; }
g_io_ota_state = IO_OTA_VERIFYING;
uint8_t digest[32];
mbedtls_sha256_finish(&sha, digest);
if (memcmp(digest, expected_digest, 32) != 0) {
ESP_LOGE(TAG, "SHA256 mismatch");
esp_ota_abort(handle);
send_nack(seq, 0x01u);
g_io_ota_state = IO_OTA_FAILED;
break;
}
if (esp_ota_end(handle) != ESP_OK ||
esp_ota_set_boot_partition(ota_part) != ESP_OK) {
send_nack(seq, 0x02u);
g_io_ota_state = IO_OTA_FAILED;
break;
}
g_io_ota_state = IO_OTA_REBOOTING;
g_io_ota_progress = 100;
ESP_LOGI(TAG, "OTA done — rebooting");
send_ack(seq);
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
break;
}
case OTA_ABORT:
if (ota_started) { esp_ota_abort(handle); ota_started = false; }
g_io_ota_state = IO_OTA_IDLE;
ESP_LOGW(TAG, "OTA aborted");
break;
default:
break;
}
}
}
void uart_ota_recv_init(void)
{
/* UART0 already initialized for inter-board comms; just create the task */
xTaskCreate(ota_recv_task, "io_ota_recv", 8192, NULL, 6, NULL);
ESP_LOGI(TAG, "OTA receiver task started");
}

View File

@ -1,20 +0,0 @@
#pragma once
/* uart_ota_recv.h — IO board: receives OTA firmware from Balance (bd-21hv) */
#include <stdint.h>
#include <stdbool.h>
typedef enum {
IO_OTA_IDLE = 0,
IO_OTA_RECEIVING,
IO_OTA_VERIFYING,
IO_OTA_APPLYING,
IO_OTA_REBOOTING,
IO_OTA_FAILED,
} io_ota_state_t;
extern volatile io_ota_state_t g_io_ota_state;
extern volatile uint8_t g_io_ota_progress;
/* Start listening for OTA frames on UART0 */
void uart_ota_recv_init(void);

View File

@ -1,7 +0,0 @@
# ESP32-S3 IO — 4 MB flash, dual OTA partitions
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x1B0000,
app1, app, ota_1, 0x1C0000, 0x1B0000,
nvs_user, data, nvs, 0x370000, 0x50000,
1 # ESP32-S3 IO — 4 MB flash, dual OTA partitions
2 # Name, Type, SubType, Offset, Size
3 nvs, data, nvs, 0x9000, 0x5000,
4 otadata, data, ota, 0xe000, 0x2000,
5 app0, app, ota_0, 0x10000, 0x1B0000,
6 app1, app, ota_1, 0x1C0000, 0x1B0000,
7 nvs_user, data, nvs, 0x370000, 0x50000,

View File

@ -1,13 +0,0 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_TASK_WDT_EN=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_UART_ISR_IN_IRAM=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# OTA — bd-3gwo: dual OTA partitions + rollback
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y

View File

@ -14,12 +14,7 @@ Self-balancing robot: Jetson Orin Nano Super dev environment for ROS2 Humble + S
| Nav | Nav2 | | Nav | Nav2 |
| Depth camera | Intel RealSense D435i | | Depth camera | Intel RealSense D435i |
| LiDAR | RPLIDAR A1M8 | | LiDAR | RPLIDAR A1M8 |
<<<<<<< HEAD
| MCU bridge | ESP32 (USB CDC @ 921600) |
=======
| MCU bridge | ESP32-S3 (USB Serial (CH343) @ 921600) | | MCU bridge | ESP32-S3 (USB Serial (CH343) @ 921600) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Quick Start ## Quick Start
```bash ```bash
@ -46,12 +41,7 @@ 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, ESP32-S3)├── README.md # This file
├── 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
├── 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 (10W envelope)

View File

@ -34,12 +34,7 @@ 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 BALANCE firmware.
=======
Recovery behaviors cannot interfere with E-stop because the emergency system operates at the motor driver level on the ESP32-S3 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.

View File

@ -12,12 +12,7 @@
# /scan — RPLIDAR A1M8 (obstacle layer) # /scan — RPLIDAR A1M8 (obstacle layer)
# /camera/depth/color/points — RealSense D435i (voxel layer) # /camera/depth/color/points — RealSense D435i (voxel layer)
# #
<<<<<<< HEAD
# Output: /cmd_vel (Twist) — ESP32 bridge consumes this topic.
=======
# Output: /cmd_vel (Twist) — ESP32-S3 bridge consumes this topic. # Output: /cmd_vel (Twist) — ESP32-S3 bridge consumes this topic.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
bt_navigator: bt_navigator:
ros__parameters: ros__parameters:
use_sim_time: false use_sim_time: false

View File

@ -97,8 +97,7 @@ services:
rgb_camera.profile:=640x480x30 rgb_camera.profile:=640x480x30
" "
# ── ESP32-S3 bridge node (bidirectional serial<->ROS2) ─────────────────────── # ── ESP32-S3 bridge node (bidirectional serial<->ROS2) ──────────────────────── esp32-bridge:
esp32-bridge:
image: saltybot/ros2-humble:jetson-orin image: saltybot/ros2-humble:jetson-orin
build: build:
context: . context: .
@ -208,9 +207,8 @@ services:
" "
# -- Remote e-stop bridge (MQTT over 4G -> ESP32-S3 CDC) --------------------- # -- Remote e-stop bridge (MQTT over 4G -> ESP32-S3 CDC) ----------------------
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to ESP32-S3. # Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to ESP32-S3. # Cellular watchdog: 5s MQTT drop in AUTO mode -> 'F\r\n' (ESTOP_CELLULAR_TIMEOUT).
# Cellular watchdog: 5s MQTT drop in AUTO mode -> 'F\r\n' (ESTOP_CELLULAR_TIMEOUT).
remote-estop: remote-estop:
image: saltybot/ros2-humble:jetson-orin image: saltybot/ros2-humble:jetson-orin
build: build:
@ -357,50 +355,6 @@ services:
" "
# ── Here4 DroneCAN GPS + NTRIP RTK client ────────────────────────────────
# Issue #725 — CubePilot Here4 RTK GPS via DroneCAN (1Mbps, SocketCAN)
# Start: docker compose up -d here4-gps
# Monitor fix: docker compose exec here4-gps ros2 topic echo /gps/rtk_status
# Configure NTRIP: set NTRIP_MOUNT, NTRIP_USER, NTRIP_PASSWORD env vars
here4-gps:
image: saltybot/ros2-humble:jetson-orin
build:
context: .
dockerfile: Dockerfile
container_name: saltybot-here4-gps
restart: unless-stopped
runtime: nvidia
network_mode: host
depends_on:
- saltybot-nav2
environment:
- ROS_DOMAIN_ID=42
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
# NTRIP credentials — set these in your .env or override at runtime
- NTRIP_MOUNT=${NTRIP_MOUNT:-}
- NTRIP_USER=${NTRIP_USER:-}
- NTRIP_PASSWORD=${NTRIP_PASSWORD:-}
volumes:
- ./ros2_ws/src:/ros2_ws/src:rw
- ./config:/config:ro
devices:
- /dev/can0:/dev/can0
cap_add:
- NET_ADMIN # needed for SocketCAN ip link set can0 up inside container
command: >
bash -c "
source /opt/ros/humble/setup.bash &&
source /ros2_ws/install/local_setup.bash 2>/dev/null || true &&
pip install python-dronecan --quiet 2>/dev/null || true &&
ros2 launch saltybot_dronecan_gps here4_gps.launch.py
can_interface:=can0
can_bitrate:=1000000
ntrip_caster:=rtk2go.com
ntrip_mount:=${NTRIP_MOUNT:-}
ntrip_user:=${NTRIP_USER:-}
ntrip_password:=${NTRIP_PASSWORD:-}
"
volumes: volumes:
saltybot-maps: saltybot-maps:
driver: local driver: local

View File

@ -1,10 +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 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
=======
## Self-Balancing Robot: ESP32-S3 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,50 +42,26 @@ i2cdetect -l
--- ---
<<<<<<< HEAD
## 1. ESP32 Bridge (USB CDC — Primary)
The ESP32 BALANCE acts as a real-time motor + IMU controller. Communication is via **USB CDC serial**.
=======
## 1. ESP32-S3 Bridge (USB Serial (CH343) — Primary) ## 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**. The ESP32-S3 acts as a real-time motor + IMU controller. Communication is via **USB Serial (CH343) serial**.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### USB Serial (CH343) Connection ### USB Serial (CH343) Connection
| Connection | Detail | | Connection | Detail |
|-----------|--------| |-----------|--------|
<<<<<<< HEAD
| Interface | USB on ESP32 BALANCE board → USB-A on Jetson |
| Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) |
| Baud rate | 921600 (configured in ESP32 BALANCE firmware) |
=======
| Interface | USB Micro-B on ESP32-S3 dev board → USB-A on Jetson | | Interface | USB Micro-B on ESP32-S3 dev board → USB-A on Jetson |
| Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) | | Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) |
| Baud rate | 921600 (configured in ESP32-S3 firmware) | | Baud rate | 921600 (configured in ESP32-S3 firmware) || Protocol | JSON telemetry RX + ASCII command TX (see bridge docs) |
>>>>>>> 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) | | Power | Powered via robot 5V bus (data-only via USB) |
### Hardware UART (Fallback — 40-pin header) ### Hardware UART (Fallback — 40-pin header)
<<<<<<< HEAD | Jetson Pin | Signal | ESP32-S3 Pin | Notes ||-----------|--------|-----------|-------|
| 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 8 (TXD0) | TX → | PA10 (UART1 RX) | Cross-connect TX→RX |
| Pin 10 (RXD0) | RX ← | PA9 (UART1 TX) | Cross-connect RX→TX | | Pin 10 (RXD0) | RX ← | PA9 (UART1 TX) | Cross-connect RX→TX |
| Pin 6 (GND) | GND | GND | Common ground **required** | | Pin 6 (GND) | GND | GND | Common ground **required** |
**Jetson device node:** `/dev/ttyTHS0` **Jetson device node:** `/dev/ttyTHS0`
**Baud rate:** 921600, 8N1 **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 **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)
```bash ```bash
# Verify UART # Verify UART
ls /dev/ttyTHS0 ls /dev/ttyTHS0
@ -99,15 +70,6 @@ sudo usermod -aG dialout $USER
picocom -b 921600 /dev/ttyTHS0 picocom -b 921600 /dev/ttyTHS0
``` ```
<<<<<<< HEAD
**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 topics (ESP32-S3 bridge node):**
| ROS2 Topic | Direction | Content | | ROS2 Topic | Direction | Content |
|-----------|-----------|--------- |-----------|-----------|---------
@ -115,8 +77,6 @@ picocom -b 921600 /dev/ttyTHS0
| `/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→ESP32-S3 | Velocity commands → `C<spd>,<str>\n` |
| `/saltybot/estop` | Jetson→ESP32-S3 | Emergency stop | | `/saltybot/estop` | Jetson→ESP32-S3 | Emergency stop |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
## 2. RealSense D435i (USB 3.1) ## 2. RealSense D435i (USB 3.1)
@ -300,12 +260,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) | ESP32-S3 CDC or host flash || Micro-USB | Debug/flash | JetPack flash only |
| 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 |
--- ---
@ -315,18 +270,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 BALANCE (fallback) |
| 10 | RXD0 | 3.3V | UART RX ← ESP32 BALANCE (fallback) |
| USB-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | ESP32 CDC |
=======
| 8 | TXD0 | 3.3V | UART TX → ESP32-S3 (fallback) | | 8 | TXD0 | 3.3V | UART TX → ESP32-S3 (fallback) |
| 10 | RXD0 | 3.3V | UART RX ← ESP32-S3 (fallback) | | 10 | RXD0 | 3.3V | UART RX ← ESP32-S3 (fallback) |
| USB-A ×2 | — | 5V | D435i, RPLIDAR | | USB-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | ESP32-S3 CDC | | USB-C | — | 5V | ESP32-S3 CDC || CSI-A (J5) | MIPI CSI-2 | — | Cameras front + left |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 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,12 +290,7 @@ 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 # ESP32-S3 USB Serial (CH343) (STMicroelectronics)KERNEL=="ttyACM*", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", \
# ESP32 USB CDC (STMicroelectronics)
=======
# 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", \
SYMLINK+="esp32-bridge", MODE="0666" SYMLINK+="esp32-bridge", MODE="0666"
# Intel RealSense D435i # Intel RealSense D435i

View File

@ -56,12 +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 bridge | 0.0 | 0.0 | 0.0 | USB Serial (CH343) | Self-powered from robot 5V || 4× IMX219 cameras | 0.2 | 2.0 | 2.4 | MIPI CSI-2 | ~0.5W per camera active |
| 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 |
| **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | | | **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | |
### Total System (from Jetson 5V barrel jack) ### Total System (from Jetson 5V barrel jack)
@ -155,12 +150,7 @@ 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 + 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) └─► Hoverboard ESC ──► Hub motors (48V loop)
``` ```

View File

@ -1,64 +0,0 @@
# saul-tee.conf — nginx site for saul-t-mote.evthings.app reverse proxy
#
# Replaces: python3 -m http.server 8080 --directory /home/seb
# Router (ASUS Merlin / OpenResty) terminates TLS on :443 and proxies to Orin:8080
#
# Deploy:
# sudo cp saul-tee.conf /etc/nginx/sites-available/saul-tee
# sudo ln -sf /etc/nginx/sites-available/saul-tee /etc/nginx/sites-enabled/saul-tee
# sudo systemctl reload nginx
#
# ROUTER REQUIREMENT for WSS to work:
# The ASUS Merlin router's nginx/OpenResty must proxy /rosbridge with
# WebSocket headers. Add to /jffs/configs/nginx.conf.add:
#
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
#
# And in the server block that proxies to 192.168.86.158:8080, add:
#
# location /rosbridge {
# proxy_pass http://192.168.86.158:8080/rosbridge;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# proxy_read_timeout 86400;
# }
# Infer WebSocket upgrade from Connection header.
# Handles the case where an upstream proxy (e.g. ASUS Merlin OpenResty)
# passes Connection: upgrade but strips the Upgrade: websocket header.
map $http_connection $ws_upgrade {
"upgrade" "websocket";
default "";
}
server {
listen 8080 default_server;
listen [::]:8080 default_server;
root /home/seb;
index index.html;
server_name _;
# Static files — replaces python3 -m http.server 8080 --directory /home/seb
location / {
try_files $uri $uri/ =404;
autoindex on;
}
# rosbridge WebSocket reverse proxy
# wss://saul-t-mote.evthings.app/rosbridge --> ws://127.0.0.1:9090
location /rosbridge {
proxy_pass http://127.0.0.1:9090/;
proxy_http_version 1.1;
proxy_set_header Upgrade $ws_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}

View File

@ -11,12 +11,7 @@ reconnect_delay: 2.0 # seconds between reconnect attempts on serial disconne
# ── saltybot_cmd_node (bidirectional) only ───────────────────────────────────── # ── saltybot_cmd_node (bidirectional) only ─────────────────────────────────────
# Heartbeat: H\n sent every heartbeat_period seconds. # Heartbeat: H\n sent every heartbeat_period seconds.
<<<<<<< HEAD # ESP32-S3 reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms) without heartbeat.heartbeat_period: 0.2 # seconds (= 200ms)
# ESP32 BALANCE reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms) without heartbeat.
=======
# ESP32-S3 reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms) without heartbeat.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
heartbeat_period: 0.2 # seconds (= 200ms)
# Twist → ESC command scaling # Twist → ESC command scaling
# speed = clamp(linear.x * speed_scale, -1000, 1000) [m/s → ESC units] # speed = clamp(linear.x * speed_scale, -1000, 1000) [m/s → ESC units]

View File

@ -1,10 +1,5 @@
# cmd_vel_bridge_params.yaml # cmd_vel_bridge_params.yaml
<<<<<<< HEAD # Configuration for cmd_vel_bridge_node — Nav2 /cmd_vel → ESP32-S3 autonomous drive.#
# Configuration for cmd_vel_bridge_node — Nav2 /cmd_vel → ESP32 BALANCE autonomous drive.
=======
# Configuration for cmd_vel_bridge_node — Nav2 /cmd_vel → ESP32-S3 autonomous drive.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
#
# Run with: # Run with:
# ros2 launch saltybot_bridge cmd_vel_bridge.launch.py # ros2 launch saltybot_bridge cmd_vel_bridge.launch.py
# Or override individual params: # Or override individual params:
@ -18,12 +13,7 @@ timeout: 0.05 # serial readline timeout (s)
reconnect_delay: 2.0 # seconds between reconnect attempts reconnect_delay: 2.0 # seconds between reconnect attempts
# ── Heartbeat ────────────────────────────────────────────────────────────────── # ── Heartbeat ──────────────────────────────────────────────────────────────────
<<<<<<< HEAD # ESP32-S3 jetson_cmd module reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms).# Keep heartbeat well below that threshold.
# ESP32 BALANCE jetson_cmd module reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms).
=======
# ESP32-S3 jetson_cmd module reverts steer to 0 after JETSON_HB_TIMEOUT_MS (500ms).
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
# Keep heartbeat well below that threshold.
heartbeat_period: 0.2 # seconds (200ms) heartbeat_period: 0.2 # seconds (200ms)
# ── Velocity limits ──────────────────────────────────────────────────────────── # ── Velocity limits ────────────────────────────────────────────────────────────
@ -58,9 +48,4 @@ ramp_rate: 500 # ESC units/second
# ── Deadman switch ───────────────────────────────────────────────────────────── # ── Deadman switch ─────────────────────────────────────────────────────────────
# If /cmd_vel is not received for this many seconds, target speed/steer are # If /cmd_vel is not received for this many seconds, target speed/steer are
# zeroed immediately. The ramp then drives the robot to a stop. # zeroed immediately. The ramp then drives the robot to a stop.
<<<<<<< HEAD # 500ms matches the ESP32-S3 jetson heartbeat timeout for consistency.cmd_vel_timeout: 0.5 # seconds
# 500ms matches the ESP32 BALANCE jetson heartbeat timeout for consistency.
=======
# 500ms matches the ESP32-S3 jetson heartbeat timeout for consistency.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
cmd_vel_timeout: 0.5 # seconds

View File

@ -1,21 +1,3 @@
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/config/stm32_cmd_params.yaml
# stm32_cmd_params.yaml — Configuration for stm32_cmd_node (ESP32-S3 IO bridge)
# Connects to ESP32-S3 IO board via USB-CDC @ 460800 baud.
# Frame format: [0xAA][LEN][TYPE][PAYLOAD][CRC8]
# Spec: docs/SAUL-TEE-SYSTEM-REFERENCE.md §5
# ── Serial port ────────────────────────────────────────────────────────────────
# Use /dev/esp32-io if udev rule is applied (see jetson/docs/udev-rules.md).
# ESP32-S3 IO appears as USB-JTAG/Serial device; no external UART bridge needed.
serial_port: /dev/esp32-io
baud_rate: 460800
reconnect_delay: 2.0 # seconds between reconnect attempts
# ── Heartbeat ─────────────────────────────────────────────────────────────────
# HEARTBEAT (0x20) sent every heartbeat_period.
# ESP32 IO watchdog fires if no heartbeat for ~500 ms.
heartbeat_period: 0.2 # 200 ms → well within 500 ms watchdog
=======
# esp32_cmd_params.yaml — Configuration for esp32_cmd_node (Issue #119) # esp32_cmd_params.yaml — Configuration for esp32_cmd_node (Issue #119)
# Binary-framed Jetson↔ESP32-S3 bridge at 921600 baud. # Binary-framed Jetson↔ESP32-S3 bridge at 921600 baud.
@ -46,4 +28,3 @@ watchdog_timeout: 0.5 # 500ms
# Tune speed_scale to set the physical top speed. # Tune speed_scale to set the physical top speed.
speed_scale: 1000.0 speed_scale: 1000.0
steer_scale: -500.0 steer_scale: -500.0
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/config/esp32_cmd_params.yaml

View File

@ -6,12 +6,7 @@ Two deployment modes:
1. Full bidirectional (recommended for Nav2): 1. Full bidirectional (recommended for Nav2):
ros2 launch saltybot_bridge bridge.launch.py mode:=bidirectional ros2 launch saltybot_bridge bridge.launch.py mode:=bidirectional
Starts saltybot_cmd_node owns serial port, handles both RX telemetry Starts saltybot_cmd_node owns serial port, handles both RX telemetry
<<<<<<< HEAD
and TX /cmd_vel ESP32 BALANCE commands + heartbeat.
=======
and TX /cmd_vel ESP32-S3 commands + heartbeat. and TX /cmd_vel ESP32-S3 commands + heartbeat.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
2. RX-only (telemetry monitor, no drive commands): 2. RX-only (telemetry monitor, no drive commands):
ros2 launch saltybot_bridge bridge.launch.py mode:=rx_only ros2 launch saltybot_bridge bridge.launch.py mode:=rx_only
Starts serial_bridge_node telemetry RX only. Use when you want to Starts serial_bridge_node telemetry RX only. Use when you want to
@ -69,12 +64,7 @@ def generate_launch_description():
DeclareLaunchArgument("mode", default_value="bidirectional", DeclareLaunchArgument("mode", default_value="bidirectional",
description="bidirectional | rx_only"), description="bidirectional | rx_only"),
DeclareLaunchArgument("serial_port", default_value="/dev/ttyACM0", DeclareLaunchArgument("serial_port", default_value="/dev/ttyACM0",
<<<<<<< HEAD description="ESP32-S3 USB CDC device node"), DeclareLaunchArgument("baud_rate", default_value="921600"),
description="ESP32 USB CDC device node"),
=======
description="ESP32-S3 USB CDC device node"),
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
DeclareLaunchArgument("baud_rate", default_value="921600"),
DeclareLaunchArgument("speed_scale", default_value="1000.0", DeclareLaunchArgument("speed_scale", default_value="1000.0",
description="m/s → ESC units (linear.x scale)"), description="m/s → ESC units (linear.x scale)"),
DeclareLaunchArgument("steer_scale", default_value="-500.0", DeclareLaunchArgument("steer_scale", default_value="-500.0",

View File

@ -1,19 +1,9 @@
""" """
<<<<<<< HEAD
cmd_vel_bridge.launch.py Nav2 cmd_vel ESP32 BALANCE autonomous drive bridge.
=======
cmd_vel_bridge.launch.py Nav2 cmd_vel ESP32-S3 autonomous drive bridge. cmd_vel_bridge.launch.py Nav2 cmd_vel ESP32-S3 autonomous drive bridge.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Starts cmd_vel_bridge_node, which owns the serial port exclusively and provides: Starts cmd_vel_bridge_node, which owns the serial port exclusively and provides:
- /cmd_vel subscription with velocity limits + smooth ramp - /cmd_vel subscription with velocity limits + smooth ramp
- Deadman switch (zero speed if /cmd_vel silent > cmd_vel_timeout) - Deadman switch (zero speed if /cmd_vel silent > cmd_vel_timeout)
<<<<<<< HEAD - Mode gate (drives only when ESP32-S3 is in AUTONOMOUS mode, md=2) - Telemetry RX /saltybot/imu, /saltybot/balance_state, /diagnostics
- Mode gate (drives only when ESP32 BALANCE is in AUTONOMOUS mode, md=2)
=======
- Mode gate (drives only when ESP32-S3 is in AUTONOMOUS mode, md=2)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
- Telemetry RX /saltybot/imu, /saltybot/balance_state, /diagnostics
- /saltybot/cmd publisher (observability) - /saltybot/cmd publisher (observability)
Do NOT run alongside serial_bridge_node or saltybot_cmd_node on the same port. Do NOT run alongside serial_bridge_node or saltybot_cmd_node on the same port.
@ -80,21 +70,11 @@ def generate_launch_description():
description="Full path to cmd_vel_bridge_params.yaml (overrides inline args)"), description="Full path to cmd_vel_bridge_params.yaml (overrides inline args)"),
DeclareLaunchArgument( DeclareLaunchArgument(
"serial_port", default_value="/dev/ttyACM0", "serial_port", default_value="/dev/ttyACM0",
<<<<<<< HEAD description="ESP32-S3 USB CDC device node"), DeclareLaunchArgument(
description="ESP32 USB CDC device node"),
=======
description="ESP32-S3 USB CDC device node"),
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
DeclareLaunchArgument(
"baud_rate", default_value="921600"), "baud_rate", default_value="921600"),
DeclareLaunchArgument( DeclareLaunchArgument(
"heartbeat_period",default_value="0.2", "heartbeat_period",default_value="0.2",
<<<<<<< HEAD description="Heartbeat interval (s); must be < ESP32-S3 HB timeout (0.5s)"), DeclareLaunchArgument(
description="Heartbeat interval (s); must be < ESP32 BALANCE HB timeout (0.5s)"),
=======
description="Heartbeat interval (s); must be < ESP32-S3 HB timeout (0.5s)"),
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
DeclareLaunchArgument(
"max_linear_vel", default_value="0.5", "max_linear_vel", default_value="0.5",
description="Hard speed cap before scaling (m/s)"), description="Hard speed cap before scaling (m/s)"),
DeclareLaunchArgument( DeclareLaunchArgument(

View File

@ -1,16 +1,3 @@
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/launch/stm32_cmd.launch.py
"""stm32_cmd.launch.py — Launch the ESP32-S3 IO auxiliary bridge node.
Connects to ESP32-S3 IO board via USB-CDC @ 460800 baud (inter-board protocol).
Handles RC monitoring, sensor data, LED/output commands.
Primary drive path uses CAN (can_bridge_node / saltybot_can_node), not this node.
Spec: docs/SAUL-TEE-SYSTEM-REFERENCE.md §5
Usage:
ros2 launch saltybot_bridge stm32_cmd.launch.py
ros2 launch saltybot_bridge stm32_cmd.launch.py serial_port:=/dev/ttyACM0
=======
"""esp32_cmd.launch.py — Launch the binary-framed ESP32-S3 command node (Issue #119). """esp32_cmd.launch.py — Launch the binary-framed ESP32-S3 command node (Issue #119).
Usage: Usage:
@ -21,9 +8,7 @@ Usage:
ros2 launch saltybot_bridge esp32_cmd.launch.py serial_port:=/dev/ttyACM1 ros2 launch saltybot_bridge esp32_cmd.launch.py serial_port:=/dev/ttyACM1
# Custom velocity scales: # Custom velocity scales:
ros2 launch saltybot_bridge esp32_cmd.launch.py speed_scale:=800.0 steer_scale:=-400.0 ros2 launch saltybot_bridge esp32_cmd.launch.py speed_scale:=800.0 steer_scale:=-400.0"""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/launch/esp32_cmd.launch.py
"""
import os import os
from ament_index_python.packages import get_package_share_directory from ament_index_python.packages import get_package_share_directory

View File

@ -2,12 +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 Flight Controller (ESP32) telemetry from /dev/ttyTHS1 into ROS2.
=======
Bridges Flight Controller (ESP32-S3) 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
/saltybot/balance_state std_msgs/String (JSON) full PID diagnostics /saltybot/balance_state std_msgs/String (JSON) full PID diagnostics
@ -24,12 +19,7 @@ Usage:
Prerequisites: Prerequisites:
- Flight Controller connected to /dev/ttyTHS1 @ 921600 baud - Flight Controller connected to /dev/ttyTHS1 @ 921600 baud
<<<<<<< HEAD - ESP32-S3 firmware transmitting JSON telemetry frames (50 Hz) - ROS2 environment sourced (source install/setup.bash)
- 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)
Note: Note:
/dev/ttyTHS1 is the native UART1 on Jetson Orin. Verify connectivity: /dev/ttyTHS1 is the native UART1 on Jetson Orin. Verify connectivity:

View File

@ -14,12 +14,7 @@ Alert levels (SoC thresholds):
5% EMERGENCY publish zero /cmd_vel, disarm, log + alert 5% EMERGENCY publish zero /cmd_vel, disarm, log + alert
SoC source priority: SoC source priority:
<<<<<<< HEAD 1. soc_pct field from ESP32-S3 BATTERY telemetry (fuel gauge or lookup on ESP32-S3) 2. Voltage-based lookup table (3S LiPo curve) if soc_pct == 0 and voltage known
1. soc_pct field from ESP32 BATTERY telemetry (fuel gauge or lookup on ESP32 BALANCE)
=======
1. soc_pct field from ESP32-S3 BATTERY telemetry (fuel gauge or lookup on ESP32-S3)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
2. Voltage-based lookup table (3S LiPo curve) if soc_pct == 0 and voltage known
Parameters (config/battery_params.yaml): Parameters (config/battery_params.yaml):
db_path /var/log/saltybot/battery.db db_path /var/log/saltybot/battery.db
@ -324,12 +319,7 @@ class BatteryNode(Node):
self._speed_limit_pub.publish(msg) self._speed_limit_pub.publish(msg)
def _execute_safe_stop(self) -> None: def _execute_safe_stop(self) -> None:
<<<<<<< HEAD """Send zero /cmd_vel and disarm the ESP32-S3.""" self.get_logger().fatal("EMERGENCY: publishing zero /cmd_vel and disarming")
"""Send zero /cmd_vel and disarm the ESP32 BALANCE."""
=======
"""Send zero /cmd_vel and disarm the ESP32-S3."""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
self.get_logger().fatal("EMERGENCY: publishing zero /cmd_vel and disarming")
# Publish zero velocity # Publish zero velocity
zero_twist = Twist() zero_twist = Twist()
self._cmd_vel_pub.publish(zero_twist) self._cmd_vel_pub.publish(zero_twist)

View File

@ -1,10 +1,5 @@
""" """
<<<<<<< HEAD
cmd_vel_bridge_node Nav2 /cmd_vel ESP32 BALANCE drive command bridge.
=======
cmd_vel_bridge_node Nav2 /cmd_vel ESP32-S3 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,28 +11,16 @@ 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 ESP32-S3 reports md=2 (AUTONOMOUS). In any other mode (RC_MANUAL,
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,
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
acceleration is always smooth from rest. acceleration is always smooth from rest.
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. ESP32 BALANCE reverts steer to 0 after 500ms silence.
Telemetry (50 Hz from ESP32 BALANCE):
=======
H\\n heartbeat. ESP32-S3 reverts steer to 0 after 500ms silence. H\\n heartbeat. ESP32-S3 reverts steer to 0 after 500ms silence.
Telemetry (50 Hz from ESP32-S3): Telemetry (50 Hz from ESP32-S3): Same RX/publish pipeline as saltybot_cmd_node.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
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.
Topics published: Topics published:
@ -164,12 +147,7 @@ class CmdVelBridgeNode(Node):
self._open_serial() self._open_serial()
# ── Timers ──────────────────────────────────────────────────────────── # ── Timers ────────────────────────────────────────────────────────────
<<<<<<< HEAD # Telemetry read at 100 Hz (ESP32-S3 sends at 50 Hz) self._read_timer = self.create_timer(0.01, self._read_cb)
# 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)
# 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)
# Heartbeat TX # Heartbeat TX
@ -256,12 +234,7 @@ class CmdVelBridgeNode(Node):
speed = self._current_speed speed = self._current_speed
steer = self._current_steer steer = self._current_steer
<<<<<<< HEAD # Send to ESP32-S3 frame = f"C{speed},{steer}\n".encode("ascii")
# 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")
if not self._write(frame): if not self._write(frame):
self.get_logger().warn( self.get_logger().warn(
"Cannot send cmd — serial not open", "Cannot send cmd — serial not open",
@ -278,12 +251,7 @@ class CmdVelBridgeNode(Node):
# ── Heartbeat TX ────────────────────────────────────────────────────────── # ── Heartbeat TX ──────────────────────────────────────────────────────────
def _heartbeat_cb(self): def _heartbeat_cb(self):
<<<<<<< HEAD """H\\n keeps ESP32-S3 jetson_cmd heartbeat alive regardless of mode.""" self._write(b"H\n")
"""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")
# ── Telemetry RX ────────────────────────────────────────────────────────── # ── Telemetry RX ──────────────────────────────────────────────────────────
@ -404,12 +372,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 = "esp32s322" status.message = f"{state_label} [{mode_label}]"
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.level = ( status.level = (
DiagnosticStatus.OK if state == 1 else DiagnosticStatus.OK if state == 1 else
DiagnosticStatus.WARN if state == 0 else DiagnosticStatus.WARN if state == 0 else
@ -436,20 +399,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 = "esp32"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322" status.hardware_id = "esp32s322"
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-S3 IMU fault: errno={errno}") self.get_logger().error(f"ESP32-S3 IMU fault: errno={errno}")
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
# ── Lifecycle ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────
def destroy_node(self): def destroy_node(self):

View File

@ -1,31 +1,15 @@
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
"""stm32_cmd_node.py — Orin ↔ ESP32-S3 IO auxiliary bridge node.
=======
"""esp32_cmd_node.py — Full bidirectional binary-framed ESP32-S3↔Jetson bridge. """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 Connects to the ESP32-S3 IO board via USB-CDC (/dev/esp32-io) using the
inter-board binary protocol (docs/SAUL-TEE-SYSTEM-REFERENCE.md §5). inter-board binary protocol (docs/SAUL-TEE-SYSTEM-REFERENCE.md §5).
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
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): 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 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 Frame format: [0xAA][LEN][TYPE][PAYLOAD][CRC8] @ 460800 baud
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
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): 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)
@ -33,20 +17,11 @@ RX telemetry (ESP32-S3 → Jetson):
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: TX to ESP32 IO:
LED_CMD (0x10) /saltybot/leds (std_msgs/String JSON) LED_CMD (0x10) /saltybot/leds (std_msgs/String JSON)
OUTPUT_CMD (0x11) /saltybot/outputs (std_msgs/String JSON) OUTPUT_CMD (0x11) /saltybot/outputs (std_msgs/String JSON)
HEARTBEAT (0x20) sent every heartbeat_period (keep IO watchdog alive) HEARTBEAT (0x20) sent every heartbeat_period (keep IO watchdog alive)
<<<<<<< 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/ttyACM0
baud_rate 921600 baud_rate 921600
@ -54,9 +29,7 @@ Parameters (config/esp32_cmd_params.yaml):
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
@ -73,13 +46,7 @@ import serial
from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue
from std_msgs.msg import String from std_msgs.msg import String
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py from .esp32_protocol import ( FrameParser,
from .stm32_protocol import (
BAUD_RATE,
=======
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,
RcChannels, RcChannels,
SensorData, SensorData,
encode_heartbeat, encode_heartbeat,
@ -88,13 +55,8 @@ from .esp32_protocol import (
) )
class Stm32CmdNode(Node): class Esp32CmdNode(Node):
<<<<<<< HEAD:jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/stm32_cmd_node.py
"""Orin ↔ ESP32-S3 IO auxiliary bridge node."""
=======
"""Binary-framed Jetson↔ESP32-S3 bridge node.""" """Binary-framed Jetson↔ESP32-S3 bridge node."""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only):jetson/ros2_ws/src/saltybot_bridge/saltybot_bridge/esp32_cmd_node.py
def __init__(self) -> None: def __init__(self) -> None:
super().__init__("esp32_cmd_node") super().__init__("esp32_cmd_node")
@ -138,13 +100,8 @@ class Stm32CmdNode(Node):
self._diag_timer = self.create_timer(1.0, self._publish_diagnostics) self._diag_timer = self.create_timer(1.0, self._publish_diagnostics)
self.get_logger().info( 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"esp32_cmd_node started — {port} @ {baud} baud | "
f"HB {int(self._hb_period * 1000)}ms | WD {int(self._wd_timeout * 1000)}ms" 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 ───────────────────────────────────────────────── # ── Serial management ─────────────────────────────────────────────────
@ -245,9 +202,6 @@ class Stm32CmdNode(Node):
type_code, _ = msg type_code, _ = msg
self.get_logger().debug(f"Unknown inter-board type 0x{type_code:02X}") 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,8 +312,6 @@ 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:
self._write(encode_heartbeat()) self._write(encode_heartbeat())
@ -399,14 +351,8 @@ class Stm32CmdNode(Node):
diag = DiagnosticArray() diag = DiagnosticArray()
diag.header.stamp = self.get_clock().now().to_msg() diag.header.stamp = self.get_clock().now().to_msg()
status = DiagnosticStatus() 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.name = "saltybot/esp32_cmd_node"
status.hardware_id = "esp32s322" 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
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 status.level = DiagnosticStatus.OK if port_ok else DiagnosticStatus.ERROR
status.message = "Serial OK" if port_ok else f"Disconnected: {self._port_name}" status.message = "Serial OK" if port_ok else f"Disconnected: {self._port_name}"
@ -436,7 +382,7 @@ class Stm32CmdNode(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,16 +1,8 @@
""" """
<<<<<<< HEAD
remote_estop_node.py -- Remote e-stop bridge: MQTT -> ESP32 USB CDC
{"kill": true} -> writes 'E\n' to ESP32 BALANCE (ESTOP_REMOTE, immediate motor cutoff)
{"kill": false} -> writes 'Z\n' to ESP32 BALANCE (clear latch, robot can re-arm)
=======
remote_estop_node.py -- Remote e-stop bridge: MQTT -> ESP32-S3 USB CDC remote_estop_node.py -- Remote e-stop bridge: MQTT -> ESP32-S3 USB CDC
{"kill": true} -> writes 'E\n' to ESP32-S3 (ESTOP_REMOTE, immediate motor cutoff) {"kill": true} -> writes 'E\n' to ESP32-S3 (ESTOP_REMOTE, immediate motor cutoff)
{"kill": false} -> writes 'Z\n' to ESP32-S3 (clear latch, robot can re-arm) {"kill": false} -> writes 'Z\n' to ESP32-S3 (clear latch, robot can re-arm)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Cellular watchdog: if MQTT link drops for > cellular_timeout_s while in Cellular watchdog: if MQTT link drops for > cellular_timeout_s while in
AUTO mode, automatically sends 'F\n' (ESTOP_CELLULAR_TIMEOUT). AUTO mode, automatically sends 'F\n' (ESTOP_CELLULAR_TIMEOUT).

View File

@ -9,13 +9,13 @@ back to FC over CAN.
CAN interface: SocketCAN (CANable USB adapter on vcan0 / can0) CAN interface: SocketCAN (CANable USB adapter on vcan0 / can0)
FC Orin (telemetry): FC Orin (telemetry):
0x400 FC_STATUS int16 pitch_x10, int16 motor_cmd, uint16 vbat_mv, 0x400 BALANCE_STATUS int16 pitch_x10, int16 motor_cmd, uint16 vbat_mv,
uint8 balance_state, uint8 flags (10 Hz) uint8 balance_state, uint8 flags (10 Hz)
0x401 FC_VESC int16 left_rpm_x10, int16 right_rpm_x10, 0x401 BALANCE_VESC int16 left_rpm_x10, int16 right_rpm_x10,
int16 left_cur_x10, int16 right_cur_x10 (10 Hz) int16 left_cur_x10, int16 right_cur_x10 (10 Hz)
0x402 FC_IMU int16 pitch_x10, int16 roll_x10, int16 yaw_x10, 0x402 BALANCE_IMU int16 pitch_x10, int16 roll_x10, int16 yaw_x10,
uint8 cal_status, uint8 balance_state (50 Hz) uint8 cal_status, uint8 balance_state (50 Hz)
0x403 FC_BARO int32 pressure_pa, int16 temp_x10, int16 alt_cm (1 Hz) 0x403 BALANCE_BARO int32 pressure_pa, int16 temp_x10, int16 alt_cm (1 Hz)
Orin FC (commands): Orin FC (commands):
0x300 HEARTBEAT uint32 sequence counter (5 Hz) 0x300 HEARTBEAT uint32 sequence counter (5 Hz)
@ -57,10 +57,10 @@ from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue
# ---- CAN frame IDs ------------------------------------------------ # ---- CAN frame IDs ------------------------------------------------
CAN_FC_STATUS = 0x400 CAN_BALANCE_STATUS = 0x400
CAN_FC_VESC = 0x401 CAN_BALANCE_VESC = 0x401
CAN_FC_IMU = 0x402 CAN_BALANCE_IMU = 0x402
CAN_FC_BARO = 0x403 CAN_BALANCE_BARO = 0x403
CAN_HEARTBEAT = 0x300 CAN_HEARTBEAT = 0x300
CAN_DRIVE = 0x301 CAN_DRIVE = 0x301
@ -216,11 +216,11 @@ class SaltybotCanNode(Node):
def _dispatch(self, can_id: int, data: bytes): def _dispatch(self, can_id: int, data: bytes):
now = self.get_clock().now().to_msg() now = self.get_clock().now().to_msg()
if can_id == CAN_FC_IMU and len(data) >= 8: if can_id == CAN_BALANCE_IMU and len(data) >= 8:
self._handle_fc_imu(data, now) self._handle_fc_imu(data, now)
elif can_id == CAN_FC_STATUS and len(data) >= 8: elif can_id == CAN_BALANCE_STATUS and len(data) >= 8:
self._handle_fc_status(data) self._handle_fc_status(data)
elif can_id == CAN_FC_BARO and len(data) >= 8: elif can_id == CAN_BALANCE_BARO and len(data) >= 8:
self._handle_fc_baro(data, now) self._handle_fc_baro(data, now)
# ── Frame handlers ─────────────────────────────────────────────── # ── Frame handlers ───────────────────────────────────────────────
@ -322,12 +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 = "esp32s322" st.message = state_label
st.hardware_id = "esp32"
=======
st.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
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
DiagnosticStatus.ERROR) DiagnosticStatus.ERROR)

View File

@ -1,26 +1,8 @@
""" """
<<<<<<< HEAD saltybot_cmd_node full bidirectional ESP32-S3Jetson bridgeCombines telemetry RX (from serial_bridge_node) with drive command TX.
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.
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 ESP32 BALANCE):
JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics
TX path:
/cmd_vel (geometry_msgs/Twist) C<speed>,<steer>\\n ESP32 BALANCE
Heartbeat timer (200ms) H\\n ESP32 BALANCE
Protocol:
H\\n heartbeat. ESP32 BALANCE reverts steer to 0 if gap > 500ms.
C<spd>,<str>\\n drive command. speed/steer: -1000..+1000 integers.
C command also refreshes ESP32 BALANCE heartbeat timer.
=======
RX path (50Hz from ESP32-S3): RX path (50Hz from ESP32-S3):
JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics JSON telemetry /saltybot/imu, /saltybot/balance_state, /diagnostics
@ -32,8 +14,6 @@ Protocol:
H\\n heartbeat. ESP32-S3 reverts steer to 0 if gap > 500ms. H\\n heartbeat. ESP32-S3 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-S3 heartbeat timer. 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)
steer = clamp(angular.z * steer_scale, -1000, 1000) steer = clamp(angular.z * steer_scale, -1000, 1000)
@ -118,12 +98,7 @@ class SaltybotCmdNode(Node):
self._open_serial() self._open_serial()
# ── Timers ──────────────────────────────────────────────────────────── # ── Timers ────────────────────────────────────────────────────────────
<<<<<<< HEAD # Telemetry read at 100Hz (ESP32-S3 sends at 50Hz) self._read_timer = self.create_timer(0.01, self._read_cb)
# 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)
# 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,12 +263,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 = "esp32s322" status.message = state_label
status.hardware_id = "esp32"
=======
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
status.message = state_label
if state == 1: if state == 1:
status.level = DiagnosticStatus.OK status.level = DiagnosticStatus.OK
elif state == 0: elif state == 0:
@ -320,20 +290,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 = "esp32"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322" status.hardware_id = "esp32s322"
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-S3 IMU fault: errno={errno}") 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 ─────────────────────────────────────────────────────
def _cmd_vel_cb(self, msg: Twist): def _cmd_vel_cb(self, msg: Twist):
@ -350,12 +311,7 @@ class SaltybotCmdNode(Node):
) )
def _heartbeat_cb(self): def _heartbeat_cb(self):
<<<<<<< HEAD """Send H\\n heartbeat. ESP32-S3 reverts steer to 0 if gap > 500ms.""" self._write(b"H\n")
"""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")
# ── Lifecycle ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────

View File

@ -1,11 +1,6 @@
""" """
saltybot_bridge serial_bridge_node saltybot_bridge serial_bridge_node
<<<<<<< HEAD
ESP32 USB CDC ROS2 topic publisher
=======
ESP32-S3 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>,
"m":<motor_cmd -1000..1000>,"s":<state 0-2>,"y":<yaw×10>} "m":<motor_cmd -1000..1000>,"s":<state 0-2>,"y":<yaw×10>}
@ -33,12 +28,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 ESP32-S3 balance_state_t enum_STATE_LABEL = {0: "DISARMED", 1: "ARMED", 2: "TILT_FAULT"}
# 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"}
# Sensor frame_id published in Imu header # Sensor frame_id published in Imu header
IMU_FRAME_ID = "imu_link" IMU_FRAME_ID = "imu_link"
@ -91,15 +81,10 @@ 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 — ESP32-S3 sends at 50 Hz, so we never miss a frame self._timer = self.create_timer(0.01, self._read_cb)
# 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.get_logger().info( self.get_logger().info(
f"stm32_serial_bridge started — {port} @ {baud} baud" f"esp32_serial_bridge started — {port} @ {baud} baud"
) )
# ── Serial management ───────────────────────────────────────────────────── # ── Serial management ─────────────────────────────────────────────────────
@ -129,12 +114,7 @@ class SerialBridgeNode(Node):
def write_serial(self, data: bytes) -> bool: def write_serial(self, data: bytes) -> bool:
""" """
<<<<<<< HEAD Send raw bytes to ESP32-S3 over the open serial port. Returns False if port is not open (caller should handle gracefully).
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).
Note: for bidirectional use prefer saltybot_cmd_node which owns TX natively. Note: for bidirectional use prefer saltybot_cmd_node which owns TX natively.
""" """
if self._ser is None or not self._ser.is_open: if self._ser is None or not self._ser.is_open:
@ -222,12 +202,7 @@ class SerialBridgeNode(Node):
""" """
Publish sensor_msgs/Imu. Publish sensor_msgs/Imu.
<<<<<<< HEAD The ESP32-S3 IMU gives Euler angles (pitch/roll from accelerometer+gyro fusion, yaw from gyro integration). We publish them as angular_velocity
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
for immediate use by slam_toolbox / robot_localization. for immediate use by slam_toolbox / robot_localization.
Note: orientation quaternion is left zeroed (covariance [-1,...]) until Note: orientation quaternion is left zeroed (covariance [-1,...]) until
@ -284,12 +259,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 = "esp32s322" status.message = state_label
status.hardware_id = "esp32"
=======
status.hardware_id = "esp32s322"
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
status.message = state_label
if state == 1: # ARMED if state == 1: # ARMED
status.level = DiagnosticStatus.OK status.level = DiagnosticStatus.OK
@ -317,20 +287,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 = "esp32"
status.message = f"IMU fault errno={errno}"
diag.status.append(status)
self._diag_pub.publish(diag)
self.get_logger().error(f"ESP32 BALANCE reported IMU fault: errno={errno}")
=======
status.hardware_id = "esp32s322" status.hardware_id = "esp32s322"
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-S3 reported IMU fault: errno={errno}") 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()
super().destroy_node() super().destroy_node()

View File

@ -29,12 +29,7 @@ setup(
zip_safe=True, zip_safe=True,
maintainer="sl-jetson", maintainer="sl-jetson",
maintainer_email="sl-jetson@saltylab.local", maintainer_email="sl-jetson@saltylab.local",
<<<<<<< HEAD description="ESP32-S3 USB CDC → ROS2 serial bridge for saltybot", license="MIT",
description="ESP32 USB CDC → ROS2 serial bridge for saltybot",
=======
description="ESP32-S3 USB CDC → ROS2 serial bridge for saltybot",
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
license="MIT",
tests_require=["pytest"], tests_require=["pytest"],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
@ -45,14 +40,8 @@ setup(
# Nav2 cmd_vel bridge: velocity limits + ramp + deadman + mode gate # Nav2 cmd_vel bridge: velocity limits + ramp + deadman + mode gate
"cmd_vel_bridge_node = saltybot_bridge.cmd_vel_bridge_node:main", "cmd_vel_bridge_node = saltybot_bridge.cmd_vel_bridge_node:main",
"remote_estop_node = saltybot_bridge.remote_estop_node:main", "remote_estop_node = saltybot_bridge.remote_estop_node:main",
<<<<<<< HEAD
# Binary-framed ESP32 BALANCE command node (Issue #119)
"stm32_cmd_node = saltybot_bridge.stm32_cmd_node:main",
=======
# Binary-framed ESP32-S3 command node (Issue #119) # Binary-framed ESP32-S3 command node (Issue #119)
"esp32_cmd_node = saltybot_bridge.esp32_cmd_node:main", "esp32_cmd_node = saltybot_bridge.esp32_cmd_node:main", # Battery management node (Issue #125)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
# Battery management node (Issue #125)
"battery_node = saltybot_bridge.battery_node:main", "battery_node = saltybot_bridge.battery_node:main",
# Production CAN bridge: FC telemetry RX + /cmd_vel TX over CAN (Issues #680, #672, #685) # Production CAN bridge: FC telemetry RX + /cmd_vel TX over CAN (Issues #680, #672, #685)
"saltybot_can_node = saltybot_bridge.saltybot_can_node:main", "saltybot_can_node = saltybot_bridge.saltybot_can_node:main",

View File

@ -1,10 +1,5 @@
""" """
<<<<<<< HEAD Unit tests for JetsonESP32-S3 command serialization logic.Tests Twistspeed/steer conversion and frame formatting.
Unit tests for JetsonESP32 BALANCE command serialization logic.
=======
Unit tests for JetsonESP32-S3 command serialization logic.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Tests Twistspeed/steer conversion and frame formatting.
Run with: pytest jetson/ros2_ws/src/saltybot_bridge/test/test_cmd.py Run with: pytest jetson/ros2_ws/src/saltybot_bridge/test/test_cmd.py
""" """

View File

@ -1,4 +1,4 @@
"""test_esp32_cmd_node.py — Unit tests for Stm32CmdNode with mock serial port. """test_esp32_cmd_node.py — Unit tests for Esp32CmdNode with mock serial port.
Tests: Tests:
- Serial open/close lifecycle - Serial open/close lifecycle

View File

@ -1,10 +1,5 @@
""" """
<<<<<<< HEAD Unit tests for ESP32-S3 telemetry parsing logic.Run with: pytest jetson/ros2_ws/src/saltybot_bridge/test/test_parse.py
Unit tests for ESP32 BALANCE telemetry parsing logic.
=======
Unit tests for ESP32-S3 telemetry parsing logic.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Run with: pytest jetson/ros2_ws/src/saltybot_bridge/test/test_parse.py
""" """
import json import json

View File

@ -19,12 +19,7 @@
# inflation_radius: 0.3m (robot_radius 0.15m + 0.15m padding) # inflation_radius: 0.3m (robot_radius 0.15m + 0.15m padding)
# DepthCostmapLayer in-layer inflation: 0.10m (pre-inflation before inflation_layer) # DepthCostmapLayer in-layer inflation: 0.10m (pre-inflation before inflation_layer)
# #
<<<<<<< HEAD
# Output: /cmd_vel (Twist) — ESP32 bridge consumes this topic.
=======
# Output: /cmd_vel (Twist) — ESP32-S3 bridge consumes this topic. # Output: /cmd_vel (Twist) — ESP32-S3 bridge consumes this topic.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
bt_navigator: bt_navigator:
ros__parameters: ros__parameters:
use_sim_time: false use_sim_time: false

View File

@ -53,14 +53,7 @@ rosbridge_websocket:
"/vesc/left/state", "/vesc/left/state",
"/vesc/right/state", "/vesc/right/state",
"/tf", "/tf",
"/tf_static", "/tf_static"]
"/saltybot/phone/gps",
"/saltybot/phone/imu",
"/saltybot/phone/battery",
"/saltybot/phone/bridge/status",
"/gps/fix",
"/gps/vel",
"/saltybot/ios/gps"]
services_glob: "[]" # no service calls via WebSocket services_glob: "[]" # no service calls via WebSocket
params_glob: "[]" # no parameter access via WebSocket params_glob: "[]" # no parameter access via WebSocket
@ -74,7 +67,7 @@ rosbridge_websocket:
# Delay between consecutive outgoing messages (ms). 0 = unlimited. # Delay between consecutive outgoing messages (ms). 0 = unlimited.
# Set > 0 (e.g. 10) if browser JS event loop is overwhelmed. # Set > 0 (e.g. 10) if browser JS event loop is overwhelmed.
delay_between_messages: 0.0 delay_between_messages: 0
# ── Logging ─────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────
# Set to true to log every publish/subscribe call (verbose, dev only). # Set to true to log every publish/subscribe call (verbose, dev only).

View File

@ -2,12 +2,7 @@
# Master configuration for full stack bringup # Master configuration for full stack bringup
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
<<<<<<< HEAD # HARDWARE — ESP32-S3 Bridge & Motor Control# ────────────────────────────────────────────────────────────────────────────
# HARDWARE — ESP32 BALANCE Bridge & Motor Control
=======
# HARDWARE — ESP32-S3 Bridge & Motor Control
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
# ────────────────────────────────────────────────────────────────────────────
saltybot_bridge_node: saltybot_bridge_node:
ros__parameters: ros__parameters:

View File

@ -39,8 +39,7 @@ Modes
UWB driver (2-anchor DW3000, publishes /uwb/target) UWB driver (2-anchor DW3000, publishes /uwb/target)
YOLOv8n person detection (TensorRT) YOLOv8n person detection (TensorRT)
Person follower with UWB+camera fusion Person follower with UWB+camera fusion
cmd_vel bridge ESP32-S3 (deadman + ramp + AUTONOMOUS gate) cmd_vel bridge ESP32-S3 (deadman + ramp + AUTONOMOUS gate) rosbridge WebSocket (port 9090)
rosbridge WebSocket (port 9090)
outdoor outdoor
RPLIDAR + RealSense D435i sensors (no SLAM) RPLIDAR + RealSense D435i sensors (no SLAM)
@ -58,8 +57,7 @@ Launch sequence (wall-clock delays — conservative for cold start)
t= 0s robot_description (URDF + TF tree) t= 0s robot_description (URDF + TF tree)
t= 0s ESP32-S3 bridge (serial port owner must be first) t= 0s ESP32-S3 bridge (serial port owner must be first)
t= 2s cmd_vel bridge (consumes /cmd_vel, needs ESP32-S3 bridge up) t= 2s cmd_vel bridge (consumes /cmd_vel, needs ESP32-S3 bridge up) t= 2s sensors (RPLIDAR + RealSense)
t= 2s sensors (RPLIDAR + RealSense)
t= 4s UWB driver (independent serial device) t= 4s UWB driver (independent serial device)
t= 4s CSI cameras (optional, independent) t= 4s CSI cameras (optional, independent)
t= 5s audio_pipeline (Jabra SPEAK 810: wake word + STT + TTS; Issue #503) t= 5s audio_pipeline (Jabra SPEAK 810: wake word + STT + TTS; Issue #503)
@ -74,8 +72,7 @@ Safety wiring
ESP32-S3 bridge must be up before cmd_vel bridge sends any command. ESP32-S3 bridge must be up before cmd_vel bridge sends any command.
cmd_vel bridge has 500ms deadman: stops robot if /cmd_vel goes silent. cmd_vel bridge has 500ms deadman: stops robot if /cmd_vel goes silent.
ESP32-S3 AUTONOMOUS mode gate (md=2) in cmd_vel bridge robot stays still ESP32-S3 AUTONOMOUS mode gate (md=2) in cmd_vel bridge robot stays still
until ESP32-S3 firmware is in AUTONOMOUS mode regardless of /cmd_vel. until ESP32-S3 firmware is in AUTONOMOUS mode regardless of /cmd_vel. follow_enabled:=false disables person follower without stopping the node.
follow_enabled:=false disables person follower without stopping the node.
To e-stop at runtime: ros2 topic pub /saltybot/estop std_msgs/Bool '{data: true}' To e-stop at runtime: ros2 topic pub /saltybot/estop std_msgs/Bool '{data: true}'
Topics published by this stack Topics published by this stack
@ -91,8 +88,7 @@ Topics published by this stack
/person/target PoseStamped (camera position, base_link) /person/target PoseStamped (camera position, base_link)
/person/detections Detection2DArray /person/detections Detection2DArray
/cmd_vel Twist (from follower or Nav2) /cmd_vel Twist (from follower or Nav2)
/saltybot/cmd String (to ESP32-S3) /saltybot/cmd String (to ESP32-S3) /saltybot/imu Imu
/saltybot/imu Imu
/saltybot/balance_state String /saltybot/balance_state String
""" """
@ -209,8 +205,7 @@ def generate_launch_description():
enable_bridge_arg = DeclareLaunchArgument( enable_bridge_arg = DeclareLaunchArgument(
"enable_bridge", "enable_bridge",
default_value="true", default_value="true",
description="Launch ESP32-S3 serial bridge + cmd_vel bridge (disable for sim/rosbag)", description="Launch ESP32-S3 serial bridge + cmd_vel bridge (disable for sim/rosbag)", )
)
enable_rosbridge_arg = DeclareLaunchArgument( enable_rosbridge_arg = DeclareLaunchArgument(
"enable_rosbridge", "enable_rosbridge",
@ -218,7 +213,7 @@ def generate_launch_description():
description="Launch rosbridge WebSocket server (port 9090)", description="Launch rosbridge WebSocket server (port 9090)",
) )
enable_mission_logging_arg = DeclareLaunchArgument( enable_mission_logging_arg = DeclareLaunchArgument(
"enable_mission_logging", "enable_mission_logging",
default_value="true", default_value="true",
description="Launch ROS2 bag recorder for mission logging (Issue #488)", description="Launch ROS2 bag recorder for mission logging (Issue #488)",
@ -270,8 +265,7 @@ def generate_launch_description():
esp32_port_arg = DeclareLaunchArgument( esp32_port_arg = DeclareLaunchArgument(
"esp32_port", "esp32_port",
default_value="/dev/esp32-bridge", default_value="/dev/esp32-bridge",
description="ESP32-S3 USB CDC serial port", description="ESP32-S3 USB CDC serial port", )
)
# ── Shared substitution handles ─────────────────────────────────────────── # ── Shared substitution handles ───────────────────────────────────────────
# Profile argument for parameter override (Issue #506) # Profile argument for parameter override (Issue #506)
@ -290,8 +284,7 @@ def generate_launch_description():
launch_arguments={"use_sim_time": use_sim_time}.items(), launch_arguments={"use_sim_time": use_sim_time}.items(),
) )
# ── t=0s ESP32-S3 bidirectional serial bridge ─────────────────────────────── # ── t=0s ESP32-S3 bidirectional serial bridge ──────────────────────────────── esp32_bridge = GroupAction(
esp32_bridge = GroupAction(
condition=IfCondition(LaunchConfiguration("enable_bridge")), condition=IfCondition(LaunchConfiguration("enable_bridge")),
actions=[ actions=[
IncludeLaunchDescription( IncludeLaunchDescription(
@ -320,8 +313,7 @@ def generate_launch_description():
], ],
) )
# ── t=2s cmd_vel safety bridge (depends on ESP32-S3 bridge) ──────────────── # ── t=2s cmd_vel safety bridge (depends on ESP32-S3 bridge) ──────────────── cmd_vel_bridge = TimerAction(
cmd_vel_bridge = TimerAction(
period=2.0, period=2.0,
actions=[ actions=[
GroupAction( GroupAction(

View File

@ -19,12 +19,7 @@ Usage
Startup sequence Startup sequence
<<<<<<< HEAD GROUP A Drivers t= 0 s ESP32-S3 bridge, RealSense+RPLIDAR, motor daemon, IMU health gate t= 8 s (full/debug)
GROUP A Drivers t= 0 s ESP32 bridge, RealSense+RPLIDAR, motor daemon, IMU
=======
GROUP A Drivers t= 0 s ESP32-S3 bridge, RealSense+RPLIDAR, motor daemon, IMU
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
health gate t= 8 s (full/debug)
GROUP B Perception t= 8 s UWB, person detection, object detection, depth costmap, gimbal GROUP B Perception t= 8 s UWB, person detection, object detection, depth costmap, gimbal
health gate t=16 s (full/debug) health gate t=16 s (full/debug)
GROUP C Navigation t=16 s SLAM, Nav2, lidar avoidance, follower, docking GROUP C Navigation t=16 s SLAM, Nav2, lidar avoidance, follower, docking
@ -127,12 +122,7 @@ def generate_launch_description() -> LaunchDescription: # noqa: C901
esp32_port_arg = DeclareLaunchArgument( esp32_port_arg = DeclareLaunchArgument(
"esp32_port", "esp32_port",
default_value="/dev/esp32-bridge", default_value="/dev/esp32-bridge",
<<<<<<< HEAD description="ESP32-S3 USART bridge serial device", )
description="ESP32 UART bridge serial device",
=======
description="ESP32-S3 USART bridge serial device",
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
)
uwb_port_a_arg = DeclareLaunchArgument( uwb_port_a_arg = DeclareLaunchArgument(
"uwb_port_a", "uwb_port_a",
@ -206,12 +196,7 @@ def generate_launch_description() -> LaunchDescription: # noqa: C901
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# GROUP A — DRIVERS (t = 0 s, all profiles) # GROUP A — DRIVERS (t = 0 s, all profiles)
<<<<<<< HEAD # Dependency order: ESP32-S3 bridge first, then sensors, then motor daemon. # Health gate: subsequent groups delayed until t_perception (8 s full/debug).
# Dependency order: ESP32 bridge first, then sensors, then motor daemon.
=======
# Dependency order: ESP32-S3 bridge first, then sensors, then motor daemon.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
# Health gate: subsequent groups delayed until t_perception (8 s full/debug).
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
group_a_banner = LogInfo( group_a_banner = LogInfo(
@ -224,12 +209,7 @@ def generate_launch_description() -> LaunchDescription: # noqa: C901
launch_arguments={"use_sim_time": use_sim_time}.items(), launch_arguments={"use_sim_time": use_sim_time}.items(),
) )
<<<<<<< HEAD # ESP32-S3 bidirectional bridge (JLINK USART1) esp32_bridge = IncludeLaunchDescription(
# ESP32 BALANCE bridge
=======
# ESP32-S3 bidirectional bridge (JLINK USART1)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
esp32_bridge = IncludeLaunchDescription(
_launch("saltybot_bridge", "launch", "bridge.launch.py"), _launch("saltybot_bridge", "launch", "bridge.launch.py"),
launch_arguments={ launch_arguments={
"mode": "bidirectional", "mode": "bidirectional",
@ -248,12 +228,7 @@ def generate_launch_description() -> LaunchDescription: # noqa: C901
], ],
) )
<<<<<<< HEAD # Motor daemon: /cmd_vel → ESP32-S3 DRIVE frames (depends on bridge at t=0) motor_daemon = TimerAction(
# Motor daemon: /cmd_vel → ESP32 BALANCE DRIVE frames (depends on bridge at t=0)
=======
# Motor daemon: /cmd_vel → ESP32-S3 DRIVE frames (depends on bridge at t=0)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
motor_daemon = TimerAction(
period=2.5, period=2.5,
actions=[ actions=[
LogInfo(msg="[saltybot_bringup] t=2.5s Starting motor daemon"), LogInfo(msg="[saltybot_bringup] t=2.5s Starting motor daemon"),

View File

@ -20,12 +20,7 @@ theta is kept in (−π, π] after every step.
Int32 rollover Int32 rollover
-------------- --------------
<<<<<<< HEAD ESP32-S3 encoder counters are int32 and wrap at ±2^31. `unwrap_delta` handlesthis by detecting jumps larger than half the int32 range and adjusting by the
ESP32 BALANCE encoder counters are int32 and wrap at ±2^31. `unwrap_delta` handles
=======
ESP32-S3 encoder counters are int32 and wrap at ±2^31. `unwrap_delta` handles
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
this by detecting jumps larger than half the int32 range and adjusting by the
full range: full range:
if delta > 2^30 : delta -= 2^31 if delta > 2^30 : delta -= 2^31

View File

@ -69,12 +69,7 @@ class Profile:
t_ui: float = 22.0 # Group D (nav2 needs ~4 s to load costmaps) t_ui: float = 22.0 # Group D (nav2 needs ~4 s to load costmaps)
# ── Safety ──────────────────────────────────────────────────────────── # ── Safety ────────────────────────────────────────────────────────────
<<<<<<< HEAD watchdog_timeout_s: float = 5.0 # max silence from ESP32-S3 bridge (s) cmd_vel_deadman_s: float = 0.5 # cmd_vel watchdog in bridge
watchdog_timeout_s: float = 5.0 # max silence from ESP32 bridge (s)
=======
watchdog_timeout_s: float = 5.0 # max silence from ESP32-S3 bridge (s)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
cmd_vel_deadman_s: float = 0.5 # cmd_vel watchdog in bridge
max_linear_vel: float = 0.5 # m/s cap passed to bridge + follower max_linear_vel: float = 0.5 # m/s cap passed to bridge + follower
follow_distance_m: float = 1.5 # target follow distance (m) follow_distance_m: float = 1.5 # target follow distance (m)
@ -94,12 +89,7 @@ class Profile:
# ── Profile factory ──────────────────────────────────────────────────────────── # ── Profile factory ────────────────────────────────────────────────────────────
def _minimal() -> Profile: def _minimal() -> Profile:
<<<<<<< HEAD
"""Minimal: ESP32 bridge + sensors + motor daemon.
=======
"""Minimal: ESP32-S3 bridge + sensors + motor daemon. """Minimal: ESP32-S3 bridge + sensors + motor daemon.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
Safe drive control only. No AI, no nav, no social. Safe drive control only. No AI, no nav, no social.
Boot time ~4 s. RAM ~400 MB. Boot time ~4 s. RAM ~400 MB.
""" """

View File

@ -1,12 +1,7 @@
""" """
wheel_odom_node.py Differential drive wheel encoder odometry (Issue #184). wheel_odom_node.py Differential drive wheel encoder odometry (Issue #184).
<<<<<<< HEAD Subscribes to raw encoder tick counts from the ESP32-S3 bridge, integratesdifferential drive kinematics, and publishes nav_msgs/Odometry at 50 Hz.
Subscribes to raw encoder tick counts from the ESP32 bridge, integrates
=======
Subscribes to raw encoder tick counts from the ESP32-S3 bridge, integrates
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
differential drive kinematics, and publishes nav_msgs/Odometry at 50 Hz.
Optionally broadcasts the odom base_link TF transform. Optionally broadcasts the odom base_link TF transform.
Subscribes: Subscribes:

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=CANable 2.0 CAN bus bringup (can0, 1 Mbps DroneCAN — Here4 GPS) Description=CANable 2.0 CAN bus bringup (can0, 500 kbps)
Documentation=https://gitea.vayrette.com/seb/saltylab-firmware/issues/643 Documentation=https://gitea.vayrette.com/seb/saltylab-firmware/issues/643
# Wait until the gs_usb net device appears; udev fires After=sys-subsystem-net-devices-can0.device # Wait until the gs_usb net device appears; udev fires After=sys-subsystem-net-devices-can0.device
After=network.target sys-subsystem-net-devices-can0.device After=network.target sys-subsystem-net-devices-can0.device
@ -10,7 +10,7 @@ BindsTo=sys-subsystem-net-devices-can0.device
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
ExecStart=/usr/sbin/ip link set can0 up type can bitrate 1000000 ExecStart=/usr/sbin/ip link set can0 up type can bitrate 500000
ExecStop=/usr/sbin/ip link set can0 down ExecStop=/usr/sbin/ip link set can0 down
StandardOutput=journal StandardOutput=journal

View File

@ -61,12 +61,7 @@ kill %1
### Core System Components ### Core System Components
- Robot Description (URDF/TF tree) - Robot Description (URDF/TF tree)
<<<<<<< HEAD - ESP32-S3 Serial Bridge- cmd_vel Bridge
- ESP32 Serial 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,12 +124,7 @@ free -h
### cmd_vel bridge not responding ### cmd_vel bridge not responding
```bash ```bash
<<<<<<< HEAD # Verify ESP32-S3 bridge is running firstros2 node list | grep bridge
# 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
# Check serial port # Check serial port
ls -l /dev/esp32-bridge ls -l /dev/esp32-bridge

View File

@ -2,10 +2,6 @@
# Exposes the adapter as can0 via the SocketCAN subsystem. # Exposes the adapter as can0 via the SocketCAN subsystem.
# Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/643 # Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/643
# #
# Bitrate is set by can-bringup.service (1 Mbps DroneCAN for Here4 GPS, bd-p47c).
# This rule only assigns the interface name and tags it for systemd; it does NOT
# bring up the interface — that is handled by can-bringup.service.
#
# Install: # Install:
# sudo cp 70-canable.rules /etc/udev/rules.d/ # sudo cp 70-canable.rules /etc/udev/rules.d/
# sudo udevadm control --reload && sudo udevadm trigger # sudo udevadm control --reload && sudo udevadm trigger
@ -20,4 +16,4 @@
SUBSYSTEM=="net", ACTION=="add", \ SUBSYSTEM=="net", ACTION=="add", \
ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="606f", \ ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="606f", \
NAME="can0", \ NAME="can0", \
TAG+="systemd" RUN+="/sbin/ip link set can0 up type can bitrate 500000"

View File

@ -1,31 +0,0 @@
# ESP32-S3 USB serial devices (bd-wim1)
#
# ESP32-S3 BALANCE (Waveshare LCD 1.28) — USB CDC via CH343 USB-UART chip
# Appears as /dev/ttyACM0, symlinked to /dev/esp32-balance
# idVendor = 1a86 (QinHeng Electronics / WCH)
# idProduct = 55d4 (CH343 USB-UART)
#
# ESP32-S3 IO (bare board JTAG USB) — native USB CDC
# Appears as /dev/ttyACM1, symlinked to /dev/esp32-io
# idVendor = 303a (Espressif)
# idProduct = 1001 (ESP32-S3 USB CDC)
#
# Install:
# sudo cp 80-esp32.rules /etc/udev/rules.d/
# sudo udevadm control --reload && sudo udevadm trigger
#
# Verify:
# ls -la /dev/esp32-*
# python3 -c "import serial; s=serial.Serial('/dev/esp32-balance', 460800); s.close(); print('OK')"
# ESP32-S3 BALANCE — CH343 USB-UART (bd-wim1 UART serial bridge)
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", \
SYMLINK+="esp32-balance", \
TAG+="systemd", \
MODE="0660", GROUP="dialout"
# ESP32-S3 IO — Espressif native USB CDC
SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", \
SYMLINK+="esp32-io", \
TAG+="systemd", \
MODE="0660", GROUP="dialout"

View File

@ -3,5 +3,5 @@ can_bridge_node:
can_interface: slcan0 can_interface: slcan0
left_vesc_can_id: 56 left_vesc_can_id: 56
right_vesc_can_id: 68 right_vesc_can_id: 68
mamba_can_id: 1 balance_can_id: 1
command_timeout_s: 0.5 command_timeout_s: 0.5

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

@ -39,22 +39,22 @@ from sensor_msgs.msg import BatteryState
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_IDLE, MODE_IDLE,
encode_drive_cmd, encode_velocity_cmd,
encode_arm_cmd, encode_mode_cmd,
encode_estop_cmd, encode_estop_cmd,
decode_attitude, decode_imu_telem,
decode_battery, decode_battery_telem,
decode_vesc_status1, decode_vesc_state,
) )
# Reconnect attempt interval when CAN bus is lost # Reconnect attempt interval when CAN bus is lost
@ -179,10 +179,10 @@ 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."""
@ -190,7 +190,7 @@ class CanBridgeNode(Node):
return return
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")
@ -201,7 +201,8 @@ class CanBridgeNode(Node):
if not self._connected: if not self._connected:
return return
if time.monotonic() - self._last_cmd_time > self._cmd_timeout: if time.monotonic() - self._last_cmd_time > self._cmd_timeout:
self._send_can(ORIN_CMD_DRIVE, encode_drive_cmd(0, 0, MODE_IDLE), "watchdog") self._send_can(BALANCE_CMD_VELOCITY, encode_velocity_cmd(0.0, 0.0), "watchdog")
self._send_can(BALANCE_CMD_MODE, encode_mode_cmd(MODE_IDLE), "watchdog mode")
# ── CAN send helper ─────────────────────────────────────────────────── # ── CAN send helper ───────────────────────────────────────────────────
@ -236,25 +237,28 @@ class CanBridgeNode(Node):
continue continue
self._dispatch_frame(frame) self._dispatch_frame(frame)
# VESC STATUS packet type = 9 (upper byte of extended arb_id)
_VESC_STATUS_PKT: int = 9
def _dispatch_frame(self, frame: can.Message) -> None: def _dispatch_frame(self, frame: can.Message) -> None:
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_l = (self._VESC_STATUS_PKT << 8) | self._left_vesc_id
vesc_r = (VESC_STATUS_1 << 8) | self._right_vesc_id vesc_r = (self._VESC_STATUS_PKT << 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)
elif arb_id == ESP32_TELEM_BATTERY: elif arb_id == BALANCE_TELEM_BATTERY:
self._handle_battery(data) self._handle_battery(data)
elif arb_id == vesc_l: elif arb_id == vesc_l:
t = decode_vesc_status1(self._left_vesc_id, data) t = decode_vesc_state(data)
m = Float32MultiArray() m = Float32MultiArray()
m.data = [t.erpm, t.duty, 0.0, t.current] m.data = [t.erpm, t.duty, t.voltage, t.current]
self._pub_vesc_left.publish(m) self._pub_vesc_left.publish(m)
elif arb_id == vesc_r: elif arb_id == vesc_r:
t = decode_vesc_status1(self._right_vesc_id, data) t = decode_vesc_state(data)
m = Float32MultiArray() m = Float32MultiArray()
m.data = [t.erpm, t.duty, 0.0, t.current] m.data = [t.erpm, t.duty, t.voltage, t.current]
self._pub_vesc_right.publish(m) self._pub_vesc_right.publish(m)
except Exception as exc: except Exception as exc:
self.get_logger().warning( self.get_logger().warning(
@ -263,20 +267,18 @@ class CanBridgeNode(Node):
# ── Frame handlers ──────────────────────────────────────────────────── # ── Frame handlers ────────────────────────────────────────────────────
_STATE_LABEL = {0: "IDLE", 1: "RUNNING", 2: "FAULT"} def _handle_imu(self, data: bytes) -> None:
"""BALANCE_TELEM_IMU (0x200): accel + gyro → /saltybot/attitude."""
def _handle_attitude(self, data: bytes) -> None: t = decode_imu_telem(data)
"""ATTITUDE (0x400): pitch, speed, yaw_rate, state, flags → /saltybot/attitude."""
t = decode_attitude(data)
now = self.get_clock().now().to_msg() now = self.get_clock().now().to_msg()
payload = { payload = {
"pitch_deg": round(t.pitch_deg, 2), "accel_x": round(t.accel_x, 4),
"speed_mps": round(t.speed, 3), "accel_y": round(t.accel_y, 4),
"yaw_rate": round(t.yaw_rate, 3), "accel_z": round(t.accel_z, 4),
"state": t.state, "gyro_x": round(t.gyro_x, 4),
"state_label": self._STATE_LABEL.get(t.state, f"UNKNOWN({t.state})"), "gyro_y": round(t.gyro_y, 4),
"flags": t.flags, "gyro_z": round(t.gyro_z, 4),
"ts": f"{now.sec}.{now.nanosec:09d}", "ts": f"{now.sec}.{now.nanosec:09d}",
} }
msg = String() msg = String()
msg.data = json.dumps(payload) msg.data = json.dumps(payload)
@ -284,11 +286,12 @@ class CanBridgeNode(Node):
self._pub_balance.publish(msg) # keep /saltybot/balance_state alive self._pub_balance.publish(msg) # keep /saltybot/balance_state alive
def _handle_battery(self, data: bytes) -> None: def _handle_battery(self, data: bytes) -> None:
"""BATTERY (0x401): vbat_mv, fault_code, rssi → /can/battery.""" """BALANCE_TELEM_BATTERY (0x201): voltage + current → /can/battery."""
t = decode_battery(data) t = decode_battery_telem(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 = t.voltage
msg.current = t.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)
@ -305,8 +308,9 @@ class CanBridgeNode(Node):
def destroy_node(self) -> None: def destroy_node(self) -> None:
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") # Send zero velocity and idle mode on shutdown
self._send_can(ORIN_CMD_ARM, encode_arm_cmd(False), "shutdown") self._send_can(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:

View File

@ -15,12 +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-S3 BALANCE controller and VESC telemetry", license="MIT",
description="CAN bus bridge for ESP32 IO motor 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",
tests_require=["pytest"], tests_require=["pytest"],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [

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,42 +4,39 @@ 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 OrinBALANCE (ESP32-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 BALANCE (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): BALANCE (ESP32-S3 BALANCE) -> Orin telemetry (standard 11-bit, matching orin_can.h):
FC_STATUS 0x400 8 bytes (see orin_can_fc_status_t) BALANCE_STATUS 0x400 8 bytes (see orin_can_balance_status_t)
FC_VESC 0x401 8 bytes (see orin_can_fc_vesc_t) BALANCE_VESC 0x401 8 bytes (see orin_can_balance_vesc_t)
FC_IMU 0x402 8 bytes BALANCE_IMU 0x402 8 bytes
FC_BARO 0x403 8 bytes BALANCE_BARO 0x403 8 bytes
Mamba VESC internal commands (matching balance_protocol.py): ESP32-S3 BALANCE <-> VESC internal commands (matching balance_protocol.py):
======= BALANCE_CMD_VELOCITY 0x100 8 bytes left_mps (f32) | right_mps (f32) big-endian
ESP32-S3 BALANCE VESC internal commands (matching balance_protocol.py): BALANCE_CMD_MODE 0x101 1 byte mode (0=idle,1=drive,2=estop)
>>>>>>> 9aed963 (fix: scrub remaining Mamba references in can_bridge and e2e test protocol files) BALANCE_CMD_ESTOP 0x102 1 byte 0x01=stop
MAMBA_CMD_VELOCITY 0x100 8 bytes left_mps (f32) | right_mps (f32) big-endian
MAMBA_CMD_MODE 0x101 1 byte mode (0=idle,1=drive,2=estop)
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
Payload: int32 RPM (BE), int16 current×10 (BE), int16 duty×1000 (BE) Payload: int32 RPM (BE), int16 current x10 (BE), int16 duty x1000 (BE)
""" """
import struct import struct
from typing import Tuple from typing import Tuple
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Orin → FC (ESP32-S3 BALANCE) command IDs (from orin_can.h) # Orin -> BALANCE (ESP32-S3 BALANCE) command IDs (from orin_can.h)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ORIN_CMD_HEARTBEAT: int = 0x300 ORIN_CMD_HEARTBEAT: int = 0x300
@ -48,28 +45,25 @@ 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) # BALANCE (ESP32-S3 BALANCE) -> Orin telemetry IDs (from orin_can.h)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FC_STATUS: int = 0x400 BALANCE_STATUS: int = 0x400
FC_VESC: int = 0x401 BALANCE_VESC: int = 0x401
FC_IMU: int = 0x402 BALANCE_IMU: int = 0x402
FC_BARO: int = 0x403 BALANCE_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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mode constants # Mode constants
@ -111,7 +105,7 @@ def VESC_SET_RPM_ID(vesc_node_id: int) -> int:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Frame builders — Orin → FC # Frame builders — Orin -> BALANCE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_heartbeat(seq: int = 0) -> bytes: def build_heartbeat(seq: int = 0) -> bytes:
@ -125,8 +119,8 @@ def build_drive_cmd(speed: int, steer: int) -> bytes:
Parameters Parameters
---------- ----------
speed: int, 1000..+1000 (mapped directly to int16) speed: int, -1000..+1000 (mapped directly to int16)
steer: int, 1000..+1000 steer: int, -1000..+1000
""" """
return struct.pack(">hh", int(speed), int(steer)) return struct.pack(">hh", int(speed), int(steer))
@ -137,20 +131,17 @@ def build_mode_cmd(mode: int) -> bytes:
def build_estop_cmd(action: int = 1) -> bytes: def build_estop_cmd(action: int = 1) -> bytes:
"""Build an ORIN_CMD_ESTOP payload. action=1 → ESTOP, 0 → CLEAR.""" """Build an ORIN_CMD_ESTOP payload. action=1 -> ESTOP, 0 -> CLEAR."""
return struct.pack(">B", action & 0xFF) return struct.pack(">B", action & 0xFF)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 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 x float32 big-endian).
Matches encode_velocity_cmd() in balance_protocol.py. Matches encode_velocity_cmd() in balance_protocol.py.
""" """
@ -158,10 +149,10 @@ def build_velocity_cmd(left_mps: float, right_mps: float) -> bytes:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Frame builders — FC → Orin telemetry # Frame builders — BALANCE -> Orin telemetry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_fc_status( def build_balance_status(
pitch_x10: int = 0, pitch_x10: int = 0,
motor_cmd: int = 0, motor_cmd: int = 0,
vbat_mv: int = 24000, vbat_mv: int = 24000,
@ -169,9 +160,9 @@ def build_fc_status(
flags: int = 0, flags: int = 0,
) -> bytes: ) -> bytes:
""" """
Build an FC_STATUS (0x400) payload. Build a BALANCE_STATUS (0x400) payload.
Layout (orin_can_fc_status_t, 8 bytes, big-endian): Layout (orin_can_balance_status_t, 8 bytes, big-endian):
int16 pitch_x10 int16 pitch_x10
int16 motor_cmd int16 motor_cmd
uint16 vbat_mv uint16 vbat_mv
@ -188,23 +179,23 @@ def build_fc_status(
) )
def build_fc_vesc( def build_balance_vesc(
left_rpm_x10: int = 0, left_rpm_x10: int = 0,
right_rpm_x10: int = 0, right_rpm_x10: int = 0,
left_current_x10: int = 0, left_current_x10: int = 0,
right_current_x10: int = 0, right_current_x10: int = 0,
) -> bytes: ) -> bytes:
""" """
Build an FC_VESC (0x401) payload. Build a BALANCE_VESC (0x401) payload.
Layout (orin_can_fc_vesc_t, 8 bytes, big-endian): Layout (orin_can_balance_vesc_t, 8 bytes, big-endian):
int16 left_rpm_x10 int16 left_rpm_x10
int16 right_rpm_x10 int16 right_rpm_x10
int16 left_current_x10 int16 left_current_x10
int16 right_current_x10 int16 right_current_x10
RPM values are RPM / 10 (e.g. 3000 RPM 300). RPM values are RPM / 10 (e.g. 3000 RPM -> 300).
Current values are A × 10 (e.g. 5.5 A 55). Current values are A x 10 (e.g. 5.5 A -> 55).
""" """
return struct.pack( return struct.pack(
">hhhh", ">hhhh",
@ -225,8 +216,8 @@ def build_vesc_status(
Layout (from vesc_can.h / VESC FW 6.x, big-endian): Layout (from vesc_can.h / VESC FW 6.x, big-endian):
int32 rpm int32 rpm
int16 current × 10 int16 current x 10
int16 duty × 1000 int16 duty x 1000
Total: 8 bytes. Total: 8 bytes.
""" """
return struct.pack( return struct.pack(
@ -241,9 +232,9 @@ def build_vesc_status(
# Frame parsers # Frame parsers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_fc_status(data: bytes): def parse_balance_status(data: bytes):
""" """
Parse an FC_STATUS (0x400) payload. Parse a BALANCE_STATUS (0x400) payload.
Returns Returns
------- -------
@ -251,7 +242,7 @@ def parse_fc_status(data: bytes):
estop_active (bool), armed (bool) estop_active (bool), armed (bool)
""" """
if len(data) < 8: if len(data) < 8:
raise ValueError(f"FC_STATUS needs 8 bytes, got {len(data)}") raise ValueError(f"BALANCE_STATUS needs 8 bytes, got {len(data)}")
pitch_x10, motor_cmd, vbat_mv, balance_state, flags = struct.unpack( pitch_x10, motor_cmd, vbat_mv, balance_state, flags = struct.unpack(
">hhHBB", data[:8] ">hhHBB", data[:8]
) )
@ -266,9 +257,9 @@ def parse_fc_status(data: bytes):
} }
def parse_fc_vesc(data: bytes): def parse_balance_vesc(data: bytes):
""" """
Parse an FC_VESC (0x401) payload. Parse a BALANCE_VESC (0x401) payload.
Returns Returns
------- -------
@ -276,7 +267,7 @@ def parse_fc_vesc(data: bytes):
right_current_x10, left_rpm (float), right_rpm (float) right_current_x10, left_rpm (float), right_rpm (float)
""" """
if len(data) < 8: if len(data) < 8:
raise ValueError(f"FC_VESC needs 8 bytes, got {len(data)}") raise ValueError(f"BALANCE_VESC needs 8 bytes, got {len(data)}")
left_rpm_x10, right_rpm_x10, left_cur_x10, right_cur_x10 = struct.unpack( left_rpm_x10, right_rpm_x10, left_cur_x10, right_cur_x10 = struct.unpack(
">hhhh", data[:8] ">hhhh", data[:8]
) )
@ -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 x 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

@ -4,7 +4,7 @@ test_drive_command.py — Integration tests for the drive command path.
Tests verify: Tests verify:
DRIVE cmd ESP32-S3 BALANCE receives velocity command frame mock VESC status response DRIVE cmd ESP32-S3 BALANCE receives velocity command frame mock VESC status response
FC_VESC broadcast contains correct RPMs. BALANCE_VESC broadcast contains correct RPMs.
All tests run without real hardware or a running ROS2 system. All tests run without real hardware or a running ROS2 system.
Run with: python -m pytest test/test_drive_command.py -v Run with: python -m pytest test/test_drive_command.py -v
@ -14,19 +14,19 @@ 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, BALANCE_VESC,
MODE_DRIVE, MODE_DRIVE,
MODE_IDLE, MODE_IDLE,
VESC_CAN_ID_LEFT, VESC_CAN_ID_LEFT,
VESC_CAN_ID_RIGHT, VESC_CAN_ID_RIGHT,
VESC_STATUS_ID, VESC_STATUS_ID,
build_velocity_cmd, build_velocity_cmd,
build_fc_vesc, build_balance_vesc,
build_vesc_status, build_vesc_status,
parse_velocity_cmd, parse_velocity_cmd,
parse_fc_vesc, parse_balance_vesc,
) )
from saltybot_can_bridge.balance_protocol import ( from saltybot_can_bridge.balance_protocol import (
encode_velocity_cmd, encode_velocity_cmd,
@ -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,26 +77,26 @@ 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])
def test_drive_forward_fc_vesc_broadcast(self, mock_can_bus): def test_drive_forward_fc_vesc_broadcast(self, mock_can_bus):
""" """
Simulate FC_VESC broadcast arriving after drive cmd; verify parse is correct. Simulate BALANCE_VESC broadcast arriving after drive cmd; verify parse is correct.
(In the real loop ESP32-S3 BALANCE computes RPM from m/s and broadcasts FC_VESC.) (In the real loop ESP32-S3 BALANCE computes RPM from m/s and broadcasts BALANCE_VESC.)
This test checks the FC_VESC frame format and parser. This test checks the BALANCE_VESC frame format and parser.
""" """
# Simulate: 1.0 m/s → ~300 RPM × 10 = 3000 (representative, not physics) # Simulate: 1.0 m/s → ~300 RPM × 10 = 3000 (representative, not physics)
fc_payload = build_fc_vesc( fc_payload = build_balance_vesc(
left_rpm_x10=300, right_rpm_x10=300, left_rpm_x10=300, right_rpm_x10=300,
left_current_x10=50, right_current_x10=50, left_current_x10=50, right_current_x10=50,
) )
mock_can_bus.inject(FC_VESC, fc_payload) mock_can_bus.inject(BALANCE_VESC, fc_payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
assert frame is not None, "FC_VESC frame not received" assert frame is not None, "BALANCE_VESC frame not received"
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
assert parsed["left_rpm_x10"] == 300 assert parsed["left_rpm_x10"] == 300
assert parsed["right_rpm_x10"] == 300 assert parsed["right_rpm_x10"] == 300
assert abs(parsed["left_rpm"] - 3000.0) < 0.1 assert abs(parsed["left_rpm"] - 3000.0) < 0.1
@ -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))
@ -119,14 +119,14 @@ class TestDriveTurn:
assert left > 0 and right < 0 assert left > 0 and right < 0
def test_drive_turn_fc_vesc_differential(self, mock_can_bus): def test_drive_turn_fc_vesc_differential(self, mock_can_bus):
"""Simulated FC_VESC for a turn has opposite-sign RPMs.""" """Simulated BALANCE_VESC for a turn has opposite-sign RPMs."""
fc_payload = build_fc_vesc( fc_payload = build_balance_vesc(
left_rpm_x10=150, right_rpm_x10=-150, left_rpm_x10=150, right_rpm_x10=-150,
left_current_x10=30, right_current_x10=30, left_current_x10=30, right_current_x10=30,
) )
mock_can_bus.inject(FC_VESC, fc_payload) mock_can_bus.inject(BALANCE_VESC, fc_payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
assert parsed["left_rpm_x10"] > 0 assert parsed["left_rpm_x10"] > 0
assert parsed["right_rpm_x10"] < 0 assert parsed["right_rpm_x10"] < 0
@ -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

@ -6,7 +6,7 @@ Covers:
- ESTOP command halts motors immediately - ESTOP command halts motors immediately
- ESTOP persists: DRIVE commands ignored while ESTOP is active - ESTOP persists: DRIVE commands ignored while ESTOP is active
- ESTOP clear restores normal drive operation - ESTOP clear restores normal drive operation
- Firmware-side estop via FC_STATUS flags is detected correctly - Firmware-side estop via BALANCE_STATUS flags is detected correctly
No ROS2 or real CAN hardware required. No ROS2 or real CAN hardware required.
Run with: python -m pytest test/test_estop.py -v Run with: python -m pytest test/test_estop.py -v
@ -17,20 +17,20 @@ 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, BALANCE_STATUS,
MODE_IDLE, MODE_IDLE,
MODE_DRIVE, MODE_DRIVE,
MODE_ESTOP, MODE_ESTOP,
build_estop_cmd, build_estop_cmd,
build_mode_cmd, build_mode_cmd,
build_velocity_cmd, build_velocity_cmd,
build_fc_status, build_balance_status,
parse_velocity_cmd, parse_velocity_cmd,
parse_fc_status, parse_balance_status,
) )
from saltybot_can_bridge.balance_protocol import ( from saltybot_can_bridge.balance_protocol import (
encode_velocity_cmd, encode_velocity_cmd,
@ -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"
@ -207,55 +207,55 @@ class TestEstopClear:
class TestFirmwareSideEstop: class TestFirmwareSideEstop:
def test_fc_status_estop_flag_detected(self, mock_can_bus): def test_fc_status_estop_flag_detected(self, mock_can_bus):
""" """
Simulate firmware sending estop via FC_STATUS flags (bit0=estop_active). Simulate firmware sending estop via BALANCE_STATUS flags (bit0=estop_active).
Verify the Orin bridge side correctly parses the flag. Verify the Orin bridge side correctly parses the flag.
""" """
# Build FC_STATUS with estop_active bit set (flags=0x01) # Build BALANCE_STATUS with estop_active bit set (flags=0x01)
payload = build_fc_status( payload = build_balance_status(
pitch_x10=0, pitch_x10=0,
motor_cmd=0, motor_cmd=0,
vbat_mv=24000, vbat_mv=24000,
balance_state=2, # TILT_FAULT balance_state=2, # TILT_FAULT
flags=0x01, # bit0 = estop_active flags=0x01, # bit0 = estop_active
) )
mock_can_bus.inject(FC_STATUS, payload) mock_can_bus.inject(BALANCE_STATUS, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
assert frame is not None, "FC_STATUS frame not received" assert frame is not None, "BALANCE_STATUS frame not received"
parsed = parse_fc_status(bytes(frame.data)) parsed = parse_balance_status(bytes(frame.data))
assert parsed["estop_active"] is True, \ assert parsed["estop_active"] is True, \
"estop_active flag not set in FC_STATUS" "estop_active flag not set in BALANCE_STATUS"
assert parsed["balance_state"] == 2 assert parsed["balance_state"] == 2
def test_fc_status_no_estop_flag(self, mock_can_bus): def test_fc_status_no_estop_flag(self, mock_can_bus):
"""FC_STATUS with flags=0x00 must NOT set estop_active.""" """BALANCE_STATUS with flags=0x00 must NOT set estop_active."""
payload = build_fc_status(flags=0x00) payload = build_balance_status(flags=0x00)
mock_can_bus.inject(FC_STATUS, payload) mock_can_bus.inject(BALANCE_STATUS, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
parsed = parse_fc_status(bytes(frame.data)) parsed = parse_balance_status(bytes(frame.data))
assert parsed["estop_active"] is False assert parsed["estop_active"] is False
def test_fc_status_armed_flag_detected(self, mock_can_bus): def test_fc_status_armed_flag_detected(self, mock_can_bus):
"""FC_STATUS flags bit1=armed must parse correctly.""" """BALANCE_STATUS flags bit1=armed must parse correctly."""
payload = build_fc_status(flags=0x02) # bit1 = armed payload = build_balance_status(flags=0x02) # bit1 = armed
mock_can_bus.inject(FC_STATUS, payload) mock_can_bus.inject(BALANCE_STATUS, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
parsed = parse_fc_status(bytes(frame.data)) parsed = parse_balance_status(bytes(frame.data))
assert parsed["armed"] is True assert parsed["armed"] is True
assert parsed["estop_active"] is False assert parsed["estop_active"] is False
def test_fc_status_roundtrip(self, mock_can_bus): def test_fc_status_roundtrip(self, mock_can_bus):
"""build_fc_status → inject → recv → parse_fc_status must be identity.""" """build_balance_status → inject → recv → parse_balance_status must be identity."""
payload = build_fc_status( payload = build_balance_status(
pitch_x10=150, pitch_x10=150,
motor_cmd=-200, motor_cmd=-200,
vbat_mv=23800, vbat_mv=23800,
balance_state=1, balance_state=1,
flags=0x03, flags=0x03,
) )
mock_can_bus.inject(FC_STATUS, payload) mock_can_bus.inject(BALANCE_STATUS, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
parsed = parse_fc_status(bytes(frame.data)) parsed = parse_balance_status(bytes(frame.data))
assert parsed["pitch_x10"] == 150 assert parsed["pitch_x10"] == 150
assert parsed["motor_cmd"] == -200 assert parsed["motor_cmd"] == -200
assert parsed["vbat_mv"] == 23800 assert parsed["vbat_mv"] == 23800

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
test_fc_vesc_broadcast.py FC_VESC broadcast and VESC STATUS integration tests. test_fc_vesc_broadcast.py BALANCE_VESC broadcast and VESC STATUS integration tests.
Covers: Covers:
- VESC STATUS extended frame for left VESC (ID 56) triggers FC_VESC broadcast - VESC STATUS extended frame for left VESC (ID 56) triggers BALANCE_VESC broadcast
- Both left (56) and right (68) VESC STATUS combined in FC_VESC - Both left (56) and right (68) VESC STATUS combined in BALANCE_VESC
- FC_VESC broadcast rate (~10 Hz) - BALANCE_VESC broadcast rate (~10 Hz)
- current_x10 scaling matches protocol spec - current_x10 scaling matches protocol spec
No ROS2 or real CAN hardware required. No ROS2 or real CAN hardware required.
@ -19,15 +19,15 @@ 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 (
FC_VESC, BALANCE_VESC,
VESC_CAN_ID_LEFT, VESC_CAN_ID_LEFT,
VESC_CAN_ID_RIGHT, VESC_CAN_ID_RIGHT,
VESC_STATUS_ID, VESC_STATUS_ID,
VESC_SET_RPM_ID, VESC_SET_RPM_ID,
VESC_TELEM_STATE, VESC_TELEM_STATE,
build_vesc_status, build_vesc_status,
build_fc_vesc, build_balance_vesc,
parse_fc_vesc, parse_balance_vesc,
parse_vesc_status, parse_vesc_status,
) )
from saltybot_can_bridge.balance_protocol import ( from saltybot_can_bridge.balance_protocol import (
@ -44,8 +44,8 @@ class VescStatusAggregator:
""" """
Simulates the firmware logic that: Simulates the firmware logic that:
1. Receives VESC STATUS extended frames from left/right VESCs 1. Receives VESC STATUS extended frames from left/right VESCs
2. Builds an FC_VESC broadcast payload 2. Builds an BALANCE_VESC broadcast payload
3. Injects the FC_VESC frame onto the mock bus 3. Injects the BALANCE_VESC frame onto the mock bus
This represents the ESP32-S3 BALANCE Orin telemetry path. This represents the ESP32-S3 BALANCE Orin telemetry path.
""" """
@ -62,7 +62,7 @@ class VescStatusAggregator:
def process_vesc_status(self, arb_id: int, data: bytes) -> None: def process_vesc_status(self, arb_id: int, data: bytes) -> None:
""" """
Process an incoming VESC STATUS frame (extended 29-bit ID). Process an incoming VESC STATUS frame (extended 29-bit ID).
Updates internal state; broadcasts FC_VESC when at least one side is known. Updates internal state; broadcasts BALANCE_VESC when at least one side is known.
""" """
node_id = arb_id & 0xFF node_id = arb_id & 0xFF
parsed = parse_vesc_status(data) parsed = parse_vesc_status(data)
@ -77,17 +77,17 @@ class VescStatusAggregator:
self._right_current_x10 = parsed["current_x10"] self._right_current_x10 = parsed["current_x10"]
self._right_seen = True self._right_seen = True
# Broadcast FC_VESC whenever we receive any update # Broadcast BALANCE_VESC whenever we receive any update
self._broadcast_fc_vesc() self._broadcast_fc_vesc()
def _broadcast_fc_vesc(self) -> None: def _broadcast_fc_vesc(self) -> None:
payload = build_fc_vesc( payload = build_balance_vesc(
left_rpm_x10=self._left_rpm_x10, left_rpm_x10=self._left_rpm_x10,
right_rpm_x10=self._right_rpm_x10, right_rpm_x10=self._right_rpm_x10,
left_current_x10=self._left_current_x10, left_current_x10=self._left_current_x10,
right_current_x10=self._right_current_x10, right_current_x10=self._right_current_x10,
) )
self._bus.inject(FC_VESC, payload) self._bus.inject(BALANCE_VESC, payload)
def _inject_vesc_status(bus: MockCANBus, vesc_id: int, rpm: int, def _inject_vesc_status(bus: MockCANBus, vesc_id: int, rpm: int,
@ -105,7 +105,7 @@ def _inject_vesc_status(bus: MockCANBus, vesc_id: int, rpm: int,
class TestVescStatusToFcVesc: class TestVescStatusToFcVesc:
def test_left_vesc_status_triggers_broadcast(self, mock_can_bus): def test_left_vesc_status_triggers_broadcast(self, mock_can_bus):
""" """
Inject VESC STATUS for left VESC (ID 56) verify FC_VESC contains Inject VESC STATUS for left VESC (ID 56) verify BALANCE_VESC contains
the correct left RPM (rpm / 10). the correct left RPM (rpm / 10).
""" """
agg = VescStatusAggregator(mock_can_bus) agg = VescStatusAggregator(mock_can_bus)
@ -116,14 +116,14 @@ class TestVescStatusToFcVesc:
agg.process_vesc_status(arb_id, payload) agg.process_vesc_status(arb_id, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
assert frame is not None, "No FC_VESC broadcast after left VESC STATUS" assert frame is not None, "No BALANCE_VESC broadcast after left VESC STATUS"
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
assert parsed["left_rpm_x10"] == 300, \ assert parsed["left_rpm_x10"] == 300, \
f"left_rpm_x10 {parsed['left_rpm_x10']} != 300" f"left_rpm_x10 {parsed['left_rpm_x10']} != 300"
assert abs(parsed["left_rpm"] - 3000.0) < 1.0 assert abs(parsed["left_rpm"] - 3000.0) < 1.0
def test_right_vesc_status_triggers_broadcast(self, mock_can_bus): def test_right_vesc_status_triggers_broadcast(self, mock_can_bus):
"""Inject VESC STATUS for right VESC (ID 68) → verify right RPM in FC_VESC.""" """Inject VESC STATUS for right VESC (ID 68) → verify right RPM in BALANCE_VESC."""
agg = VescStatusAggregator(mock_can_bus) agg = VescStatusAggregator(mock_can_bus)
arb_id = VESC_STATUS_ID(VESC_CAN_ID_RIGHT) arb_id = VESC_STATUS_ID(VESC_CAN_ID_RIGHT)
@ -132,7 +132,7 @@ class TestVescStatusToFcVesc:
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
assert frame is not None assert frame is not None
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
assert parsed["right_rpm_x10"] == 200 assert parsed["right_rpm_x10"] == 200
def test_left_vesc_id_matches_constant(self): def test_left_vesc_id_matches_constant(self):
@ -150,7 +150,7 @@ class TestBothVescStatusCombined:
def test_both_vesc_status_combined_in_fc_vesc(self, mock_can_bus): def test_both_vesc_status_combined_in_fc_vesc(self, mock_can_bus):
""" """
Inject both left (56) and right (68) VESC STATUS frames. Inject both left (56) and right (68) VESC STATUS frames.
Final FC_VESC must contain both RPMs. Final BALANCE_VESC must contain both RPMs.
""" """
agg = VescStatusAggregator(mock_can_bus) agg = VescStatusAggregator(mock_can_bus)
@ -165,7 +165,7 @@ class TestBothVescStatusCombined:
build_vesc_status(rpm=-1500, current_x10=30), build_vesc_status(rpm=-1500, current_x10=30),
) )
# Drain two FC_VESC frames (one per update), check the latest # Drain two BALANCE_VESC frames (one per update), check the latest
frames = [] frames = []
while True: while True:
f = mock_can_bus.recv(timeout=0.05) f = mock_can_bus.recv(timeout=0.05)
@ -173,16 +173,16 @@ class TestBothVescStatusCombined:
break break
frames.append(f) frames.append(f)
assert len(frames) >= 2, "Expected at least 2 FC_VESC frames" assert len(frames) >= 2, "Expected at least 2 BALANCE_VESC frames"
# Last frame must have both sides # Last frame must have both sides
last = parse_fc_vesc(bytes(frames[-1].data)) last = parse_balance_vesc(bytes(frames[-1].data))
assert last["left_rpm_x10"] == 300, \ assert last["left_rpm_x10"] == 300, \
f"left_rpm_x10 {last['left_rpm_x10']} != 300" f"left_rpm_x10 {last['left_rpm_x10']} != 300"
assert last["right_rpm_x10"] == -150, \ assert last["right_rpm_x10"] == -150, \
f"right_rpm_x10 {last['right_rpm_x10']} != -150" f"right_rpm_x10 {last['right_rpm_x10']} != -150"
def test_both_vesc_currents_combined(self, mock_can_bus): def test_both_vesc_currents_combined(self, mock_can_bus):
"""Both current values must appear in FC_VESC after two STATUS frames.""" """Both current values must appear in BALANCE_VESC after two STATUS frames."""
agg = VescStatusAggregator(mock_can_bus) agg = VescStatusAggregator(mock_can_bus)
agg.process_vesc_status( agg.process_vesc_status(
VESC_STATUS_ID(VESC_CAN_ID_LEFT), VESC_STATUS_ID(VESC_CAN_ID_LEFT),
@ -198,7 +198,7 @@ class TestBothVescStatusCombined:
if f is None: if f is None:
break break
frames.append(f) frames.append(f)
last = parse_fc_vesc(bytes(frames[-1].data)) last = parse_balance_vesc(bytes(frames[-1].data))
assert last["left_current_x10"] == 55 assert last["left_current_x10"] == 55
assert last["right_current_x10"] == 42 assert last["right_current_x10"] == 42
@ -206,11 +206,11 @@ class TestBothVescStatusCombined:
class TestVescBroadcastRate: class TestVescBroadcastRate:
def test_fc_vesc_broadcast_at_10hz(self, mock_can_bus): def test_fc_vesc_broadcast_at_10hz(self, mock_can_bus):
""" """
Simulate FC_VESC broadcasts at ~10 Hz and verify the rate. Simulate BALANCE_VESC broadcasts at ~10 Hz and verify the rate.
We inject 12 frames over ~120 ms, then verify count and average interval. We inject 12 frames over ~120 ms, then verify count and average interval.
""" """
_FC_VESC_HZ = 10 _BALANCE_VESC_HZ = 10
_interval = 1.0 / _FC_VESC_HZ _interval = 1.0 / _BALANCE_VESC_HZ
timestamps = [] timestamps = []
stop_event = threading.Event() stop_event = threading.Event()
@ -219,8 +219,8 @@ class TestVescBroadcastRate:
while not stop_event.is_set(): while not stop_event.is_set():
t = time.monotonic() t = time.monotonic()
mock_can_bus.inject( mock_can_bus.inject(
FC_VESC, BALANCE_VESC,
build_fc_vesc(100, -100, 30, 30), build_balance_vesc(100, -100, 30, 30),
timestamp=t, timestamp=t,
) )
timestamps.append(t) timestamps.append(t)
@ -232,18 +232,18 @@ class TestVescBroadcastRate:
stop_event.set() stop_event.set()
t.join(timeout=0.2) t.join(timeout=0.2)
assert len(timestamps) >= 1, "No FC_VESC broadcasts in 150 ms window" assert len(timestamps) >= 1, "No BALANCE_VESC broadcasts in 150 ms window"
if len(timestamps) >= 2: if len(timestamps) >= 2:
intervals = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)] intervals = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)]
avg = sum(intervals) / len(intervals) avg = sum(intervals) / len(intervals)
# ±40 ms tolerance for OS scheduling # ±40 ms tolerance for OS scheduling
assert 0.06 <= avg <= 0.14, \ assert 0.06 <= avg <= 0.14, \
f"FC_VESC broadcast interval {avg*1000:.1f} ms not ~100 ms" f"BALANCE_VESC broadcast interval {avg*1000:.1f} ms not ~100 ms"
def test_fc_vesc_frame_is_8_bytes(self): def test_fc_vesc_frame_is_8_bytes(self):
"""FC_VESC payload must always be exactly 8 bytes.""" """BALANCE_VESC payload must always be exactly 8 bytes."""
payload = build_fc_vesc(300, -150, 55, 42) payload = build_balance_vesc(300, -150, 55, 42)
assert len(payload) == 8 assert len(payload) == 8
@ -267,16 +267,16 @@ class TestVescCurrentScaling:
assert abs(parsed["current"] - (-3.0)) < 0.01 assert abs(parsed["current"] - (-3.0)) < 0.01
def test_fc_vesc_current_x10_roundtrip(self, mock_can_bus): def test_fc_vesc_current_x10_roundtrip(self, mock_can_bus):
"""build_fc_vesc → inject → recv → parse must preserve current_x10.""" """build_balance_vesc → inject → recv → parse must preserve current_x10."""
payload = build_fc_vesc( payload = build_balance_vesc(
left_rpm_x10=200, left_rpm_x10=200,
right_rpm_x10=200, right_rpm_x10=200,
left_current_x10=55, left_current_x10=55,
right_current_x10=42, right_current_x10=42,
) )
mock_can_bus.inject(FC_VESC, payload) mock_can_bus.inject(BALANCE_VESC, payload)
frame = mock_can_bus.recv(timeout=0.1) frame = mock_can_bus.recv(timeout=0.1)
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
assert parsed["left_current_x10"] == 55 assert parsed["left_current_x10"] == 55
assert parsed["right_current_x10"] == 42 assert parsed["right_current_x10"] == 42
@ -306,7 +306,7 @@ class TestVescCurrentScaling:
) )
frame = mock_can_bus.recv(timeout=0.05) frame = mock_can_bus.recv(timeout=0.05)
assert frame is not None assert frame is not None
parsed = parse_fc_vesc(bytes(frame.data)) parsed = parse_balance_vesc(bytes(frame.data))
if vesc_id == VESC_CAN_ID_LEFT: if vesc_id == VESC_CAN_ID_LEFT:
assert parsed["left_rpm_x10"] == expected_rpm_x10, \ assert parsed["left_rpm_x10"] == expected_rpm_x10, \
f"left_rpm_x10={parsed['left_rpm_x10']} expected {expected_rpm_x10}" f"left_rpm_x10={parsed['left_rpm_x10']} expected {expected_rpm_x10}"

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

@ -27,12 +27,7 @@ robot:
stem_od: 0.0381 # m STEM_OD = 38.1mm stem_od: 0.0381 # m STEM_OD = 38.1mm
stem_height: 1.050 # m nominal cut length stem_height: 1.050 # m nominal cut length
<<<<<<< HEAD # ── FC / IMU (ESP32-S3 BALANCE) ────────────────────────────────────────────────── # fc_x = -50mm in SCAD (front = -X SCAD = +X ROS REP-105)
# ── FC / IMU (ESP32 BALANCE) ──────────────────────────────────────────────────
=======
# ── FC / IMU (ESP32-S3 BALANCE) ──────────────────────────────────────────────────
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
# fc_x = -50mm in SCAD (front = -X SCAD = +X ROS REP-105)
# z = deck_thickness/2 + mounting_pad(3mm) + standoff(6mm) = 12mm # z = deck_thickness/2 + mounting_pad(3mm) + standoff(6mm) = 12mm
imu_x: 0.050 # m forward of base_link center imu_x: 0.050 # m forward of base_link center
imu_y: 0.000 # m imu_y: 0.000 # m

View File

@ -5,12 +5,7 @@ Comprehensive hardware diagnostics and health monitoring for SaltyBot.
## Features ## Features
### Startup Checks ### Startup Checks
<<<<<<< HEAD - RPLIDAR, RealSense, VESC, Jabra mic, ESP32-S3, servos- WiFi, GPS, disk space, RAM
- RPLIDAR, RealSense, VESC, Jabra mic, ESP32 BALANCE, 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
- Boot result TTS + face animation - Boot result TTS + face animation
- JSON logging - JSON logging

View File

@ -138,12 +138,7 @@ class DiagnosticsNode(Node):
self.hardware_checks["jabra"] = ("WARN", "Audio check failed", {}) self.hardware_checks["jabra"] = ("WARN", "Audio check failed", {})
def _check_stm32(self): def _check_stm32(self):
<<<<<<< HEAD
self.hardware_checks["stm32"] = ("OK", "ESP32 bridge online", {})
=======
self.hardware_checks["stm32"] = ("OK", "ESP32-S3 bridge online", {}) self.hardware_checks["stm32"] = ("OK", "ESP32-S3 bridge online", {})
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references ESP32-S3 only)
def _check_servos(self): def _check_servos(self):
try: try:
result = subprocess.run(["i2cdetect", "-y", "1"], capture_output=True, text=True, timeout=2) result = subprocess.run(["i2cdetect", "-y", "1"], capture_output=True, text=True, timeout=2)

View File

@ -1,9 +0,0 @@
# dronecan_gps_params.yaml — CubePilot Here4 DroneCAN GPS driver defaults
# Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/725
dronecan_gps:
ros__parameters:
can_interface: "can0"
can_bitrate: 1000000 # Here4 default: 1Mbps DroneCAN
node_id: 127 # DroneCAN local node ID (GPS driver)
publish_compass: true # publish MagneticFieldStrength if available

View File

@ -1,22 +0,0 @@
here4_can_node:
ros__parameters:
# SocketCAN interface — freed from ESP32 BALANCE comms by bd-wim1
can_interface: can0
# DroneCAN bitrate — Here4 runs at 1 Mbps (not 500 kbps used previously)
bitrate: 1000000
# Local DroneCAN node ID for this Orin instance (1-127, must be unique on bus)
local_node_id: 126
# Set true to run: ip link set can0 type can bitrate 1000000 && ip link set can0 up
# Requires: sudo setcap cap_net_admin+eip $(which python3) or run as root
# Default false — manage interface externally (systemd-networkd or udev)
bring_up_can: false
# Here4 DroneCAN node ID — 0 means auto-detect from first Fix2 message
node_id_filter: 0
# TF frame IDs
fix_frame_id: gps
imu_frame_id: here4_imu

View File

@ -1,109 +0,0 @@
"""here4.launch.py — Launch the Here4 GPS DroneCAN bridge on CANable2.
bd-p47c: CANable2 freed by bd-wim1 (ESP32 moved to UART/USB) now used for
Here4 GPS at 1 Mbps DroneCAN/UAVCAN v0.
CAN setup (one-time, survives reboot via systemd-networkd or udev)
------------------------------------------------------------------
# Option A — manual (quick test):
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000
sudo ip link set can0 up
# Option B — let the node do it (requires CAP_NET_ADMIN):
ros2 launch saltybot_dronecan_gps here4.launch.py bring_up_can:=true
# Option C — systemd-networkd (/etc/systemd/network/80-can0.network):
[Match]
Name=can0
[CAN]
BitRate=1M
Published topics
----------------
/gps/fix sensor_msgs/NavSatFix navsat_transform_node (EKF)
/gps/velocity geometry_msgs/TwistWithCovarianceStamped
/here4/fix sensor_msgs/NavSatFix (alias)
/here4/imu sensor_msgs/Imu
/here4/mag sensor_msgs/MagneticField
/here4/baro sensor_msgs/FluidPressure
/here4/status std_msgs/String JSON
/here4/node_id std_msgs/Int32
Subscribed topics
-----------------
/rtcm std_msgs/ByteMultiArray RTCM corrections (NTRIP client)
/rtcm_hex std_msgs/String hex-encoded RTCM (fallback)
Usage
-----
# Default (interface already up):
ros2 launch saltybot_dronecan_gps here4.launch.py
# Bring up interface automatically (CAP_NET_ADMIN required):
ros2 launch saltybot_dronecan_gps here4.launch.py bring_up_can:=true
# Override interface (e.g. second CAN adapter):
ros2 launch saltybot_dronecan_gps here4.launch.py can_interface:=can1
# Pin to specific Here4 node ID (skip auto-detect):
ros2 launch saltybot_dronecan_gps here4.launch.py node_id_filter:=10
System dependency
-----------------
pip install dronecan # DroneCAN/UAVCAN v0 Python library
apt install can-utils # optional: candump, cansend for debugging
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
pkg_share = get_package_share_directory("saltybot_dronecan_gps")
params_file = os.path.join(pkg_share, "config", "here4_params.yaml")
args = [
DeclareLaunchArgument(
"can_interface",
default_value="can0",
description="SocketCAN interface (CANable2 @ 1 Mbps)",
),
DeclareLaunchArgument(
"bring_up_can",
default_value="false",
description="Bring up can_interface via ip link (requires CAP_NET_ADMIN)",
),
DeclareLaunchArgument(
"node_id_filter",
default_value="0",
description="DroneCAN node ID of Here4 (0=auto-detect from first Fix2)",
),
DeclareLaunchArgument(
"local_node_id",
default_value="126",
description="DroneCAN node ID for this Orin (must be unique on bus)",
),
]
node = Node(
package="saltybot_dronecan_gps",
executable="here4_node",
name="here4_can_node",
output="screen",
parameters=[
params_file,
{
"can_interface": LaunchConfiguration("can_interface"),
"bring_up_can": LaunchConfiguration("bring_up_can"),
"node_id_filter": LaunchConfiguration("node_id_filter"),
"local_node_id": LaunchConfiguration("local_node_id"),
},
],
)
return LaunchDescription([*args, node])

View File

@ -1,110 +0,0 @@
"""
here4_gps.launch.py CubePilot Here4 RTK GPS full stack
Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/725
Launches:
- dronecan_gps_node (saltybot_dronecan_gps)
- ntrip_client_node (saltybot_ntrip_client)
Usage (minimal):
ros2 launch saltybot_dronecan_gps here4_gps.launch.py \\
ntrip_mount:=RTCM3_GENERIC ntrip_user:=you@email.com
Full options:
ros2 launch saltybot_dronecan_gps here4_gps.launch.py \\
can_interface:=can0 \\
can_bitrate:=1000000 \\
ntrip_caster:=rtk2go.com \\
ntrip_port:=2101 \\
ntrip_mount:=MYBASE \\
ntrip_user:=you@email.com \\
ntrip_password:=secret
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
gps_cfg = os.path.join(
get_package_share_directory('saltybot_dronecan_gps'),
'config', 'dronecan_gps_params.yaml',
)
ntrip_cfg = os.path.join(
get_package_share_directory('saltybot_ntrip_client'),
'config', 'ntrip_params.yaml',
)
return LaunchDescription([
# ── Shared CAN args ───────────────────────────────────────────────────
DeclareLaunchArgument(
'can_interface', default_value='can0',
description='SocketCAN interface name',
),
DeclareLaunchArgument(
'can_bitrate', default_value='1000000',
description='CAN bus bitrate — Here4 default is 1000000 (1 Mbps)',
),
# ── NTRIP args ────────────────────────────────────────────────────────
DeclareLaunchArgument(
'ntrip_caster', default_value='rtk2go.com',
description='NTRIP caster hostname',
),
DeclareLaunchArgument(
'ntrip_port', default_value='2101',
description='NTRIP caster port',
),
DeclareLaunchArgument(
'ntrip_mount', default_value='',
description='NTRIP mount point (REQUIRED)',
),
DeclareLaunchArgument(
'ntrip_user', default_value='',
description='NTRIP username (rtk2go.com requires email address)',
),
DeclareLaunchArgument(
'ntrip_password', default_value='',
description='NTRIP password',
),
# ── DroneCAN GPS node ─────────────────────────────────────────────────
Node(
package='saltybot_dronecan_gps',
executable='dronecan_gps_node',
name='dronecan_gps',
output='screen',
parameters=[
gps_cfg,
{
'can_interface': LaunchConfiguration('can_interface'),
'can_bitrate': LaunchConfiguration('can_bitrate'),
},
],
),
# ── NTRIP client node ─────────────────────────────────────────────────
Node(
package='saltybot_ntrip_client',
executable='ntrip_client_node',
name='ntrip_client',
output='screen',
parameters=[
ntrip_cfg,
{
'can_interface': LaunchConfiguration('can_interface'),
'can_bitrate': LaunchConfiguration('can_bitrate'),
'ntrip_caster': LaunchConfiguration('ntrip_caster'),
'ntrip_port': LaunchConfiguration('ntrip_port'),
'ntrip_mount': LaunchConfiguration('ntrip_mount'),
'ntrip_user': LaunchConfiguration('ntrip_user'),
'ntrip_password': LaunchConfiguration('ntrip_password'),
},
],
),
])

View File

@ -1,37 +0,0 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_dronecan_gps</name>
<version>0.1.0</version>
<description>
DroneCAN/UAVCAN v0 bridge for Here4 GPS on the CANable2 SocketCAN adapter.
Publishes GPS fix, IMU, magnetometer, and barometer data to ROS2.
Injects RTCM corrections for RTK.
bd-p47c: CANable2 freed from ESP32 BALANCE comms by bd-wim1.
Here4 operates at 1 Mbps on can0 (was 500 kbps for VESC comms).
System dependency: dronecan Python library (pip install dronecan)
</description>
<maintainer email="sl-perception@saltylab.local">sl-perception</maintainer>
<license>MIT</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<!-- dronecan: pip install dronecan (DroneCAN/UAVCAN v0 Python library) -->
<!-- python3-can is a transitive dependency of dronecan -->
<buildtool_depend>ament_python</buildtool_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@ -1 +0,0 @@
saltybot_dronecan_gps

View File

@ -1 +0,0 @@
# saltybot_dronecan_gps — Here4 GPS DroneCAN bridge for CANable2 (bd-p47c)

View File

@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""
dronecan_gps_node.py DroneCAN GPS driver for CubePilot Here4 RTK
Issue: https://gitea.vayrette.com/seb/saltylab-firmware/issues/725
Subscribes to:
uavcan.equipment.gnss.Fix2 (msg ID 1063) position + fix status
uavcan.equipment.ahrs.MagneticFieldStrength compass (optional)
Publishes:
/gps/fix sensor_msgs/NavSatFix
/gps/vel geometry_msgs/TwistStamped
/gps/rtk_status std_msgs/String
DroneCAN fix_type sensor_msgs status mapping:
0 = NO_FIX STATUS_NO_FIX (-1)
1 = TIME_ONLY STATUS_NO_FIX (-1)
2 = 2D_FIX STATUS_FIX (0)
3 = 3D_FIX STATUS_FIX (0)
4 = DGPS STATUS_SBAS_FIX (1)
5 = RTK_FLOAT STATUS_GBAS_FIX (2)
6 = RTK_FIXED STATUS_GBAS_FIX (2)
"""
import math
import threading
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from sensor_msgs.msg import NavSatFix, NavSatStatus
from geometry_msgs.msg import TwistStamped
from std_msgs.msg import String
try:
import dronecan
except ImportError:
dronecan = None
_SENSOR_QOS = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=5,
)
# DroneCAN fix_type → (NavSatStatus.status, rtk_label)
_FIX_MAP = {
0: (NavSatStatus.STATUS_NO_FIX, 'NO_FIX'),
1: (NavSatStatus.STATUS_NO_FIX, 'TIME_ONLY'),
2: (NavSatStatus.STATUS_FIX, '2D_FIX'),
3: (NavSatStatus.STATUS_FIX, '3D_FIX'),
4: (NavSatStatus.STATUS_SBAS_FIX, 'DGPS'),
5: (NavSatStatus.STATUS_GBAS_FIX, 'RTK_FLOAT'),
6: (NavSatStatus.STATUS_GBAS_FIX, 'RTK_FIXED'),
}
class DroneCanGpsNode(Node):
def __init__(self) -> None:
super().__init__('dronecan_gps')
self.declare_parameter('can_interface', 'can0')
self.declare_parameter('can_bitrate', 1000000)
self.declare_parameter('node_id', 127) # DroneCAN local node ID
self.declare_parameter('publish_compass', True)
self._iface = self.get_parameter('can_interface').value
self._bitrate = self.get_parameter('can_bitrate').value
self._node_id = self.get_parameter('node_id').value
self._publish_compass = self.get_parameter('publish_compass').value
self._fix_pub = self.create_publisher(NavSatFix, '/gps/fix', _SENSOR_QOS)
self._vel_pub = self.create_publisher(TwistStamped, '/gps/vel', _SENSOR_QOS)
self._rtk_pub = self.create_publisher(String, '/gps/rtk_status', 10)
if dronecan is None:
self.get_logger().error(
'python-dronecan not installed. '
'Run: pip install python-dronecan'
)
return
self._lock = threading.Lock()
self._dc_node = None
self._spin_thread = threading.Thread(
target=self._dronecan_spin, daemon=True
)
self._spin_thread.start()
self.get_logger().info(
f'DroneCanGpsNode started — interface={self._iface} '
f'bitrate={self._bitrate}'
)
# ── DroneCAN spin (runs in background thread) ─────────────────────────────
def _dronecan_spin(self) -> None:
try:
self._dc_node = dronecan.make_node(
self._iface,
node_id=self._node_id,
bitrate=self._bitrate,
)
self._dc_node.add_handler(
dronecan.uavcan.equipment.gnss.Fix2,
self._on_fix2,
)
if self._publish_compass:
self._dc_node.add_handler(
dronecan.uavcan.equipment.ahrs.MagneticFieldStrength,
self._on_mag,
)
self.get_logger().info(
f'DroneCAN node online on {self._iface}'
)
while rclpy.ok():
self._dc_node.spin(timeout=0.1)
except Exception as exc:
self.get_logger().error(f'DroneCAN spin error: {exc}')
# ── Message handlers ──────────────────────────────────────────────────────
def _on_fix2(self, event) -> None:
msg = event.message
now = self.get_clock().now().to_msg()
fix_type = int(msg.fix_type)
nav_status, rtk_label = _FIX_MAP.get(
fix_type, (NavSatStatus.STATUS_NO_FIX, 'UNKNOWN')
)
# NavSatFix
fix = NavSatFix()
fix.header.stamp = now
fix.header.frame_id = 'gps'
fix.status.status = nav_status
fix.status.service = NavSatStatus.SERVICE_GPS
fix.latitude = math.degrees(msg.latitude_deg_1e8 * 1e-8)
fix.longitude = math.degrees(msg.longitude_deg_1e8 * 1e-8)
fix.altitude = msg.height_msl_mm * 1e-3 # mm → m
# Covariance from position_covariance if available, else diagonal guess
if hasattr(msg, 'position_covariance') and len(msg.position_covariance) >= 9:
fix.position_covariance = list(msg.position_covariance)
fix.position_covariance_type = NavSatFix.COVARIANCE_TYPE_FULL
else:
h_var = (msg.horizontal_pos_accuracy_m_1e2 * 1e-2) ** 2 \
if hasattr(msg, 'horizontal_pos_accuracy_m_1e2') else 4.0
v_var = (msg.vertical_pos_accuracy_m_1e2 * 1e-2) ** 2 \
if hasattr(msg, 'vertical_pos_accuracy_m_1e2') else 4.0
fix.position_covariance = [
h_var, 0.0, 0.0,
0.0, h_var, 0.0,
0.0, 0.0, v_var,
]
fix.position_covariance_type = NavSatFix.COVARIANCE_TYPE_DIAGONAL_KNOWN
self._fix_pub.publish(fix)
# TwistStamped velocity
if hasattr(msg, 'ned_velocity'):
vel = TwistStamped()
vel.header.stamp = now
vel.header.frame_id = 'gps'
vel.twist.linear.x = float(msg.ned_velocity[0]) # North m/s
vel.twist.linear.y = float(msg.ned_velocity[1]) # East m/s
vel.twist.linear.z = float(msg.ned_velocity[2]) # Down m/s (ROS: up+)
self._vel_pub.publish(vel)
# RTK status string
rtk_msg = String()
rtk_msg.data = rtk_label
self._rtk_pub.publish(rtk_msg)
self.get_logger().debug(
f'Fix2: {fix.latitude:.6f},{fix.longitude:.6f} '
f'alt={fix.altitude:.1f}m status={rtk_label}'
)
def _on_mag(self, event) -> None:
# Compass data logged; extend to publish /imu/mag if needed
msg = event.message
self.get_logger().debug(
f'Mag: {msg.magnetic_field_ga[0]:.3f} '
f'{msg.magnetic_field_ga[1]:.3f} '
f'{msg.magnetic_field_ga[2]:.3f} Ga'
)
def main(args=None) -> None:
rclpy.init(args=args)
node = DroneCanGpsNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More