Compare commits

...

234 Commits

Author SHA1 Message Date
b353a2ba29 Merge pull request 'feat: systemd auto-start for ROS2 + dashboard on Orin boot (bd-1hyn)' (#732) from sl-perception/bd-1hyn-orin-autostart into main 2026-04-17 23:11:15 -04:00
329797d43c Merge pull request 'feat: ESP32-S3 OTA stack — partitions, Gitea checker, self-update, UART IO, display, Orin serial trigger (6 beads)' (#731) from sl-firmware/ota-esp32 into main 2026-04-17 23:11:04 -04:00
1ae600ead4 feat: Orin serial OTA_CHECK + OTA_UPDATE commands, version reporting (bd-1s1s)
Extends the bd-66hx serial protocol with two new Orin→ESP32 commands:
  CMD_OTA_CHECK  (0x10): triggers gitea_ota_check_now(), responds with
    TELEM_VERSION_INFO (0x84) for Balance and IO (current + available ver).
  CMD_OTA_UPDATE (0x11): uint8 target (0=balance, 1=io, 2=both) — triggers
    uart_ota_trigger() for IO or ota_self_trigger() for Balance.
    NACK with ERR_OTA_BUSY or ERR_OTA_NO_UPDATE on failure.
New telemetry: TELEM_OTA_STATUS (0x83, target+state+progress+err),
  TELEM_VERSION_INFO (0x84, target+current[16]+available[16]).
Wires OTA stack into app_main: ota_self_health_check on boot,
  gitea_ota_init + ota_display_init after peripherals ready.
CMakeLists updated with all OTA component dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:52 -04:00
e73674f161 feat: GC9A01 OTA notification badge + progress ring UI (bd-1yr8)
Adds ota_display_task (5 Hz) on GC9A01 240×240 round LCD:
- Idle: orange dot badge at top-right when update available, version text
- Progress: arc sweeping 0→360° around display perimeter with phase label
- States: Downloading/Verifying/Applying/Rebooting (Balance) and
  Downloading/Sending/Done (IO via UART)
- Error: red arc + "FAILED RETRY?" prompt
Display primitives (fill_rect, draw_string, draw_arc) are stubs called
from the GC9A01 SPI driver layer (separate driver bead).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:52 -04:00
972db16635 feat: UART OTA protocol Balance→IO board, 1 KB chunk + ACK (bd-21hv)
Balance side (uart_ota.c): downloads io-firmware.bin from Gitea to RAM,
computes SHA256, then streams to IO over UART1 (GPIO17/18, 460800 baud)
as OTA_BEGIN/OTA_DATA/OTA_END frames with CRC8 + per-chunk ACK/retry (×3).
IO side (uart_ota_recv.c): receives frames, writes to inactive OTA partition
via esp_ota_write, verifies SHA256 on OTA_END, sets boot partition, reboots.
IO board main.c + CMakeLists.txt scaffold included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:52 -04:00
5250ce67ad feat: Balance self-OTA download, SHA256 verify, rollback (bd-18nb)
Downloads balance-firmware.bin from Gitea release URL to inactive OTA
partition, streams SHA256 verification via mbedTLS, sets boot partition
and reboots. Auto-rollback via CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE if
ota_self_health_check() not called within 30 s of boot. Progress 0-100%
in g_ota_self_progress for display task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:52 -04:00
d2175bf7d0 feat: Gitea release version checker with WiFi (bd-3hte)
Adds gitea_ota_check_task on Balance board: fetches Gitea releases API
every 30 min and on boot, filters by esp32-balance/ and esp32-io/ tag
prefixes, compares semver against embedded FW version, stores update info
(version string, download URL, SHA256) in g_balance_update / g_io_update.
WiFi credentials read from NVS namespace "wifi"; falls back to compile-time
DEFAULT_WIFI_SSID/PASS if NVS is empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:52 -04:00
2a13c3e18b feat: partition tables + OTA setup for Balance and IO boards (bd-3gwo)
Add dual OTA partitions (ota_0/ota_1 × 1.75 MB each) and otadata to
both esp32s3/balance/ and esp32s3/io/ on 4 MB flash layouts.
Enable CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE and OTA HTTP on Balance.
Create esp32s3/io/ project scaffold with config.h pin assignments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:10:51 -04:00
3f0508815d Merge pull request 'ci: OTA release pipeline — build + attach firmware binaries (bd-9kod)' (#730) from sl-jetson/bd-9kod-ota-ci into main 2026-04-17 23:10:26 -04:00
d9e7acfa0d Merge pull request 'feat: ESP32 Balance UART/USB protocol for Orin + VESC proxy (bd-66hx)' (#729) from sl-firmware/bd-66hx-esp32-uart-orin into main 2026-04-17 23:10:15 -04:00
c02faf3ac2 Merge pull request 'feat: Here4 GPS DroneCAN on Orin via CANable2 (bd-p47c)' (#728) from sl-perception/bd-p47c-here4-can-gps into main 2026-04-17 23:10:05 -04:00
61f241ae1d Merge pull request 'feat: Orin UART/USB serial comms with ESP32 Balance (bd-wim1)' (#727) from sl-perception/bd-wim1-orin-uart-esp32 into main 2026-04-17 23:09:53 -04:00
26e71d7a14 feat: systemd auto-start for ROS2 + dashboard on Orin boot (bd-1hyn)
Implements full boot-time auto-start for the SaltyBot ROS2 stack on
Jetson Orin. Everything comes up automatically after power-on with
correct dependency ordering and restart-on-failure for each service.

New systemd services:
  saltybot-ros2.service         full_stack.launch.py (perception + SLAM + Nav2)
  saltybot-esp32-serial.service ESP32-S3 BALANCE UART bridge (bd-wim1, PR #727)
  saltybot-here4.service        Here4 DroneCAN GPS bridge (bd-p47c, PR #728)
  saltybot-dashboard.service    Web dashboard on port 8080

Updated:
  saltybot.target               now Wants all four new services with
                                boot-order comments
  can-bringup.service           bitrate 500 kbps → 1 Mbps (DroneCAN for Here4)
  70-canable.rules              remove bitrate from udev RUN+=; let service
                                own the bitrate, add TAG+=systemd for device unit
  install_systemd.sh            installs all services + udev rules, colcon
                                build, enables mosquitto, usermod dialout
  full_stack.launch.py          resolve 8 merge conflict markers (ESP32-S3
                                rename) and fix missing indent on
                                enable_mission_logging_arg — file was
                                un-launchable with SyntaxError

New:
  scripts/ros2-launch.sh        sources ROS2 Humble + workspace overlay,
                                then exec ros2 launch — used by all
                                ROS2 service units via ExecStart=
  udev/80-esp32.rules           /dev/esp32-balance (CH343) and
                                /dev/esp32-io (ESP32-S3 native USB CDC)

Resolves bd-1hyn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:20:40 -04:00
2fa097e3d6 ci: OTA release pipeline — build + attach firmware binaries (bd-9kod)
Adds .gitea/workflows/ota-release.yml: triggered on esp32-balance/vX.Y.Z
or esp32-io/vX.Y.Z tags, builds the corresponding ESP32-S3 project with
espressif/idf:v5.2.2, and attaches <app>_<version>.bin + .sha256 to the
Gitea release for OTA download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:11:33 -04:00
b830420efc feat: add UART/USB serial protocol for Orin comms, proxy VESC CAN (bd-66hx)
Replaces Orin↔ESP32-S3 BALANCE CAN comms (0x300-0x303 / 0x400-0x401)
with binary serial framing over CH343 USB-CDC at 460800 baud.

Protocol matches bd-wim1 (sl-perception) exactly:
  Frame: [0xAA][LEN][TYPE][PAYLOAD][CRC8-SMBUS]
  CRC covers LEN+TYPE+PAYLOAD, big-endian multi-byte fields.

Commands (Orin→ESP32): HEARTBEAT/DRIVE/ESTOP/ARM/PID
Telemetry (ESP32→Orin): TELEM_STATUS, TELEM_VESC_LEFT (ID 56),
                         TELEM_VESC_RIGHT (ID 68), ACK/NACK

VESC CAN TWAI kept for motor control; drive commands from Orin
forwarded to VESCs via SET_RPM. Hardware note: SN65HVD230
rewired from IO43/44 to IO2/IO1 to free IO43/44 for CH343.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:09:12 -04:00
9d6c72bd24 feat: Here4 GPS DroneCAN integration via CANable2 (bd-p47c)
Implements saltybot_dronecan_gps ROS2 package — DroneCAN/UAVCAN v0
bridge that publishes Here4 GPS, IMU, magnetometer, and barometer data
to ROS2. CANable2 freed from ESP32 BALANCE comms (bd-wim1) now runs
Here4 at 1 Mbps DroneCAN.

Key features:
- dronecan_parser.py: pure-Python DSDL converters (Fix2, RawIMU, Mag,
  StaticPressure, StaticTemperature, NodeStatus, RTCMStream chunks),
  testable without dronecan library or CAN hardware
- here4_node.py: ROS2 node, auto-discovers Here4 node ID on first Fix2,
  publishes /gps/fix + /gps/velocity for navsat_transform EKF fusion,
  HDOP-based NavSatStatus upgrade (RTK/SBAS), RTCM injection via
  /rtcm (ByteMultiArray) or /rtcm_hex (String hex fallback)
- 39 unit tests, all passing
- bring_up_can:=true parameter to configure SocketCAN at launch

Resolves bd-p47c

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:49:00 -04:00
9b460e34db feat: Orin UART/USB serial interface for ESP32 Balance (bd-wim1)
New package saltybot_esp32_serial replaces saltybot_can_bridge
(CANable2/python-can) with direct USB-CDC serial to ESP32-S3 BALANCE
(CH343 chip, 1a86:55d3, /dev/esp32-balance @ 460800 baud).

Framing: [0xAA][LEN][TYPE][PAYLOAD][CRC8-SMBUS] matching bd-66hx spec.

esp32_balance_protocol.py — codec + streaming FrameParser (state-machine)
  - Commands: HEARTBEAT(0x01), DRIVE(0x02), ESTOP(0x03), ARM(0x04), PID(0x05)
  - Telemetry: STATUS(0x80), VESC_LEFT(0x81), VESC_RIGHT(0x82), ACK/NACK

esp32_balance_node.py — ROS2 node
  - Subs: /cmd_vel, /estop, /saltybot/arm, /saltybot/pid_update
  - Pubs: /saltybot/attitude, /saltybot/balance_state, /can/battery,
          /can/vesc/{left,right}/state, /can/connection_status
  - 500ms /cmd_vel watchdog → CMD_DRIVE(0,0)
  - 200ms CMD_HEARTBEAT keepalive timer
  - Auto-reconnect on serial disconnect

Proxied VESC telemetry: erpm, voltage, current, temp for IDs 56(L)/68(R).
Frees CANable2 for bd-p47c (Here4 GPS).
33 unit tests — all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:38:38 -04:00
c297d24a48 Merge pull request 'fix: Rename sultee-tracker → saul-tee-tracker' (#724) from sl-webui/fix-tracker-rename into main 2026-04-04 11:23:02 -04:00
885a66f24b Merge pull request 'feat: MQTT bridge for iOS GPS on /saltybot/ios/gps (Issue #681)' (#723) from sl-jetson/issue-681-ios-gps-bridge into main 2026-04-04 11:21:14 -04:00
fbc88f5c2a fix: correct rclpy logger calls to use f-strings (pre-existing bugs)
rclpy RcutilsLogger.info/warning/debug() do not accept printf-style
positional format args. Also fix p["use_phone_timestamp"] → p["use_phone_ts"]
key mismatch in __init__ log line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:20:58 -04:00
0122957b6b feat: Add iOS phone GPS MQTT-to-ROS2 bridge topic (Issue #681)
- Add _TOPIC_IOS_GPS = 'saltybot/ios/gps' constant
- Subscribe to saltybot/ios/gps in _on_mqtt_connect
- Dispatch to _handle_ios_gps() in _dispatch()
- _handle_ios_gps(): same logic as _handle_gps(), frame_id='ios_gps',
  publishes to /saltybot/ios/gps via self._ios_gps_pub
- Add rx/pub/err/last_rx_ts counters for the new topic
- Add /saltybot/ios/gps to rosbridge_params.yaml topics_glob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:20:58 -04:00
759277a7e0 fix: Rename sultee-tracker to saul-tee-tracker (typo fix) 2026-04-04 11:19:35 -04:00
b1e8da4403 Merge pull request 'feat: iOS phone GPS via rosbridge topic /saltybot/ios/gps (Issue #681)' (#722) from sl-webui/issue-681-ios-gps-rosbridge into main 2026-04-04 11:15:13 -04:00
dd8afb480f Merge pull request 'fix: add phone bridge and GPS topics to rosbridge whitelist (Issue #681)' (#721) from sl-webui/issue-681-fix-gps-topics into main 2026-04-04 11:15:12 -04:00
43fb3f1147 feat: Route iOS phone GPS through rosbridge instead of raw WebSocket (Issue #681) 2026-04-04 11:11:15 -04:00
416a393134 fix: correct delay_between_messages type to float in rosbridge_params
rclpy expects DOUBLE for this param; integer 0 raises InvalidParameterTypeException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:58:09 -04:00
60f500c206 fix: add phone bridge and GPS topics to rosbridge whitelist (Issue #681)
Add /saltybot/phone/gps, /saltybot/phone/imu, /saltybot/phone/battery,
/saltybot/phone/bridge/status, /gps/fix, /gps/vel to topics_glob so
the browser GPS dashboard can receive phone-bridged GPS data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:56:06 -04:00
b1cd15327f Merge pull request 'fix: GPS tracker subscribes to correct phone bridge topic (Issue #681)' (#720) from sl-webui/issue-681-fix-gps-topics into main 2026-04-04 10:07:10 -04:00
b72e435bf3 fix: Update tracker GPS topic to match phone bridge (Issue #681) 2026-04-04 10:01:57 -04:00
9cf98830c6 Merge pull request 'feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only' (#715) from sl-firmware/cleanup-legacy-hw into main 2026-04-04 09:00:55 -04:00
a1233dbd04 fix: scrub remaining Mamba references in can_bridge and e2e test protocol files
- balance_protocol.py: Mamba→Orin / Mamba→VESC comments → ESP32-S3 BALANCE
- can_bridge_node.py: docstring and inline comments
- __init__.py: package description
- protocol_defs.py: all Mamba references in docstring and comments
- test_fc_vesc_broadcast.py, test_drive_command.py: test comments

Zero Mamba/STM32F722/BlackPill/stm32_protocol/mamba_protocol references
now exist outside legacy/stm32/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:00:44 -04:00
fa75c442a7 feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only
Archive STM32 firmware to legacy/stm32/:
- src/, include/, lib/USB_CDC/, platformio.ini, test stubs, flash_firmware.py
- test/test_battery_adc.c, test_hw_button.c, test_pid_schedule.c, test_vesc_can.c, test_can_watchdog.c
- USB_CDC_BUG.md

Rename: stm32_protocol → esp32_protocol, mamba_protocol → balance_protocol,
  stm32_cmd_node → esp32_cmd_node, stm32_cmd_params → esp32_cmd_params,
  stm32_cmd.launch.py → esp32_cmd.launch.py,
  test_stm32_protocol → test_esp32_protocol, test_stm32_cmd_node → test_esp32_cmd_node

Content cleanup across all files:
- Mamba F722S → ESP32-S3 BALANCE
- BlackPill → ESP32-S3 IO
- STM32F722/F7xx → ESP32-S3
- stm32Mode/Version/Port → esp32Mode/Version/Port
- STM32 State/Mode labels → ESP32 State/Mode
- Jetson Nano → Jetson Orin Nano Super
- /dev/stm32 → /dev/esp32
- stm32_bridge → esp32_bridge
- STM32 HAL → ESP-IDF

docs/SALTYLAB.md:
- Update "Drone FC Details" to describe ESP32-S3 BALANCE board (Waveshare ESP32-S3 Touch LCD 1.28)
- Replace verbose "Self-Balancing Control" STM32 section with brief note pointing to SAUL-TEE-SYSTEM-REFERENCE.md

TEAM.md: Update Embedded Firmware Engineer role to ESP32-S3 / ESP-IDF

No new functionality — cleanup only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:00:38 -04:00
fe84ff6039 Merge pull request 'feat(arch): CAN/UART bridge update for SAUL-TEE ESP32 architecture' (#714) from sl-controls/esp32-can-bridge-update into main 2026-04-04 09:00:10 -04:00
fda6ab99ff feat(arch): align CAN/UART bridges with SAUL-TEE-SYSTEM-REFERENCE.md spec
Update CAN and serial bridge code to match authoritative protocol spec
from docs/SAUL-TEE-SYSTEM-REFERENCE.md §5-6 (hal, 2026-04-04).

mamba_protocol.py (CAN, Orin ↔ ESP32 BALANCE):
- 0x300 DRIVE: [speed:i16][steer:i16][mode:u8][flags:u8][_:u16] — combined frame
- 0x301 ARM:   [arm:u8]
- 0x302 PID:   [kp:f16][ki:f16][kd:f16][_:u16]  — half-float gains
- 0x303 ESTOP: [0xE5] — magic byte cut
- 0x400 ATTITUDE: [pitch:f16][speed:f16][yaw_rate:f16][state:u8][flags:u8]
- 0x401 BATTERY:  [vbat_mv:u16][fault_code:u8][rssi:i8]
- Add VESC STATUS1/4/5 decode helpers; VESC IDs 56 (left) / 68 (right)

can_bridge_node.py:
- /cmd_vel → encode_drive_cmd (speed/steer int16, MODE_DRIVE)
- /estop → encode_estop_cmd (magic 0xE5); clear → DISARM
- /saltybot/arm → encode_arm_cmd (new subscription)
- Watchdog sends DRIVE(0,0,MODE_IDLE) when /cmd_vel silent
- ATTITUDE (0x400) → /saltybot/attitude + /saltybot/balance_state JSON
- BATTERY  (0x401) → /can/battery BatteryState
- VESC STATUS1 frames → /can/vesc/left|right/state

stm32_cmd_node.py — rewritten for inter-board protocol API:
- Imports from updated stm32_protocol (BAUD_RATE=460800, new frame types)
- RX: RcChannels → /saltybot/rc_channels, SensorData → /saltybot/sensors
- TX: encode_led_cmd, encode_output_cmd from /saltybot/leds + /saltybot/outputs
- HEARTBEAT (0x20) timer replaces old SPEED_STEER/ARM logic

stm32_cmd_params.yaml: serial_port=/dev/esp32-io, baud=460800
stm32_cmd.launch.py: updated defaults and description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:59:45 -04:00
308be74330 feat(arch): implement SAUL-TEE ESP32 protocol specs from hal reference doc
Spec source: docs/SAUL-TEE-SYSTEM-REFERENCE.md (hal, 2026-04-04)

stm32_protocol.py — rewritten for inter-board UART protocol (ESP32 BALANCE ↔ IO):
- Frame: [0xAA][LEN][TYPE][PAYLOAD][CRC8] @ 460800 baud (was STX/ETX/CRC16)
- CRC-8 poly 0x07 over LEN+TYPE+PAYLOAD
- New message types: RC_CHANNELS(0x01), SENSORS(0x02), LED_CMD(0x10),
  OUTPUT_CMD(0x11), MOTOR_CMD(0x12), HEARTBEAT(0x20)

mamba_protocol.py — updated CAN IDs and frame formats:
- Orin→BALANCE: DRIVE(0x300) f32×2 LE, MODE(0x301), ESTOP(0x302), LED(0x303)
- BALANCE→Orin: FC_STATUS(0x400) pitch/vbat/state, FC_VESC(0x401) rpm/current
- VESC node IDs: Left=56, Right=68 (authoritative per §8)
- VESC extended frames: STATUS1(cmd=9), STATUS4(cmd=16), STATUS5(cmd=27)
- Replaced old MAMBA_CMD_*/MAMBA_TELEM_* constants

can_bridge_node.py — updated to use new IDs:
- ORIN_CMD_DRIVE/MODE/ESTOP replace MAMBA_CMD_VELOCITY/MODE/ESTOP
- FC_STATUS handler: publishes pitch→/can/imu, vbat_mv→/can/battery
- FC_VESC handler: publishes rpm/cur→/can/vesc/left|right/state
- VESC STATUS1 extended frames decoded per node ID (56/68)
- Removed PID CAN command (not in new spec)

CLAUDE.md — updated with ESP32-S3 BALANCE/IO hardware summary + key protocols

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:59:28 -04:00
19be6bbe11 Merge pull request 'docs: SAUL-TEE full ESP32-S3 system reference (arch migration)' (#712) from sl-firmware/arch-esp32-migration into main 2026-04-04 08:57:11 -04:00
5ef1f7e365 docs: full SAUL-TEE ESP32-S3 spec — pins, CAN, UART, RC mapping
Complete hardware reference from hal@Orin spec (2026-04-04):
- docs/SAUL-TEE-SYSTEM-REFERENCE.md: authoritative pin/protocol/CAN reference
  ESP32-S3 BALANCE: QMI8658 SPI(IO38-42), GC9A01 LCD, SN65HVD230 CAN(IO43/44),
  inter-board UART(IO17/18)
  ESP32-S3 IO: Crossfire UART0(IO43/44), ELRS UART2(IO16/17), BTS7960(IO1-8),
  I2C(IO11/12), WS2812(IO13), buzzer/headlight/fan, arming btn, kill-sw, UART(IO18/21)
- Inter-board binary protocol: [0xAA][LEN][TYPE][PAYLOAD][CRC8] @ 460800 baud
- CAN: VESC L=68, R=56; Orin cmds 0x300-0x303; telemetry 0x400-0x401 @ 10Hz
- RC: CH5=ARM, CH6=ESTOP, CH7=speed-limit; CRSF loss >100ms = motors cut
- CLAUDE.md, TEAM.md, docs/AGENTS.md, docs/SALTYLAB.md updated with full spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:25:24 -04:00
bfca6d1d92 docs: Add SAUL-TEE system reference + update wiring diagram
- docs/SAUL-TEE-SYSTEM-REFERENCE.md: authoritative architecture doc for
  the new 4-wheel wagon. Covers ESP32-S3 BALANCE (Waveshare LCD 1.28,
  QMI8658, SN65HVD230 CAN), ESP32-S3 IO (TBS Crossfire, ELRS, BTS7960,
  NFC/baro/ToF, WS2812), inter-board UART protocol (460800 baud,
  [0xAA][len][type][payload][crc8]), CAN IDs (VESCs 68/56, Orin
  0x300-0x303 cmd / 0x400-0x401 telemetry), RC channel map, power
  architecture, safety systems, and firmware layout.

- docs/wiring-diagram.md: banner pointing to new reference doc;
  old Mamba F722S UART summary marked OBSOLETE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:25:24 -04:00
f71dad5344 feat(arch): migrate all STM32/Mamba/BlackPill refs to ESP32 BALANCE/IO + fix roslib@1.4.0
Architecture change (2026-04-03): Mamba F722S (STM32F722) and BlackPill
replaced by ESP32 BALANCE (PID loop) and ESP32 IO (motors/sensors/comms).

- Update CLAUDE.md, docs, chassis BOM/ASSEMBLY, pinout, power-budget,
  wiring-diagram, TEAM.md, AUTONOMOUS_ARMING.md, docker-compose
- Update all ROS2 package comments, config labels, launch args
  (stm32_port→esp32_port, /dev/stm32-bridge→/dev/esp32-bridge)
- Update WebUI: stm32Mode→esp32Mode, stm32Version→esp32Version,
  "STM32 State/Mode" labels → "ESP32 State/Mode" (ControlMode, SettingsPanel)
- Add TODO(esp32-migration) markers on stm32_protocol.py and mamba_protocol.py
  binary frame layouts — pending ESP32 protocol spec from max
- Fix roslib CDN 1.3.0→1.4.0 in all 11 HTML panels (fixes ROS2 Humble
  rosbridge "Received a message without an op" incompatibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:25:24 -04:00
5e97676703 docs: Update chassis docs for ESP32 architecture (retire Mamba F722S)
Replace Mamba F722S / STM32F722 references in BOM.md and ASSEMBLY.md
with ESP32 BALANCE + ESP32 IO. Board dimensions marked TBD pending
spec from max.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:25:24 -04:00
30b0f245e1 docs: retire Mamba F722S/BlackPill, adopt ESP32 BALANCE + ESP32 IO architecture
Effective 2026-04-03: STM32F722 flight controller no longer used.
New architecture:
- ESP32 BALANCE: PID balance loop
- ESP32 IO: motors, sensors, comms

Updated: CLAUDE.md, TEAM.md, docs/AGENTS.md, docs/SALTYLAB.md
Legacy src/ STM32 firmware is archived — not extended.
Source code migration pending ESP32 hardware spec from max.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:25:24 -04:00
7db6158ada Merge pull request 'feat: Robot GPS live map panel (Issue #709 companion)' (#711) from sl-webui/robot-gps-map into main 2026-04-03 22:43:56 -04:00
f0d9fead74 Merge pull request 'feat: Sul-Tee GPS live tracking dashboard (Issue #709)' (#710) from sl-webui/issue-709-gps-tracker into main 2026-04-03 22:43:55 -04:00
811a2ccc5c fix(sultee-tracker): subscribe to proper ROS GPS topics for robot marker
Switch robot GPS subscription from custom saltybot/gps/* std_msgs/String
topics to the canonical /gps/fix (sensor_msgs/NavSatFix) and /gps/vel
(geometry_msgs/TwistStamped) published by the SIM7600X GPS driver node.

- /gps/fix: read msg.latitude/longitude/altitude/status.status directly
- /gps/vel: compute speed (sqrt(vx²+vy²) * 3.6 km/h) and heading
  (angular.z radians → degrees) from ENU velocity components

Closes #709

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:41:47 -04:00
bb354336c3 feat(sultee-tracker): add dual device map — phone (blue) + robot (orange)
Previously showed only phone GPS. Now also subscribes via ROSLIB to
saltybot/gps/fix + saltybot/gps/vel on the same rosbridge URL for
robot (SAUL-TEE) position. Blue marker+trail for phone (raw WS
{type:gps}), orange marker+trail for robot (ROS topics). Sidebar shows
phone speed/alt/heading/accuracy + robot lat/lon/speed + distance
between the two. FIT ALL button auto-zooms to show both. Status bar
badges for phone staleness and robot fix/vel freshness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:38:28 -04:00
6d047ca50c feat(gps-map): add phone/user GPS as second marker on robot GPS map
Subscribes to saltybot/phone/gps (JSON: {ts, lat, lon, alt_m,
accuracy_m, speed_ms, bearing_deg, provider}) and renders a blue
Leaflet marker + blue breadcrumb trail alongside the robot's
orange/cyan marker. Status bar now shows PHONE badge with stale
detection. Sidebar adds phone lat/lon/speed/accuracy/provider section.
Clear button resets both trails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:34:44 -04:00
f384cc4810 feat: Robot GPS live map panel (Issue #709 companion)
Adds gps_map_panel.html/css/js — standalone dashboard panel:

- Leaflet.js + OpenStreetMap with dark CSS filter (matches dashboard theme)
- Heading-aware SVG robot marker (orange arrow shows direction of travel)
- Orange breadcrumb trail polyline (up to 2000 pts, CLEAR button)
- FOLLOW mode auto-pan; drag map to switch to FREE mode
- Sidebar: speed (km/h, color-coded), altitude, heading compass rose,
  fix status (0=NO FIX…4=RTK), fix count, lat/lon, trail log
- Exponential backoff auto-reconnect (2s→30s cap)
- Stale detection at 5s for fix + velocity badges

Subscribes via rosbridge to:
  saltybot/gps/fix  std_msgs/String JSON — {lat, lon, alt, stat, t}
  saltybot/gps/vel  std_msgs/String JSON — {spd, hdg, t}

index.html: new GPS MAP card (🛰️, #709) before CAN MONITOR
dashboard.js: gpsWatch subscription + 'gps' panel entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:28:44 -04:00
2560718b39 feat: Sul-Tee GPS live tracking dashboard (Issue #709)
Single-file vanilla JS dashboard at ui/sultee-tracker.html:

- Connects to ws://100.64.0.2:9090 (configurable, saved in localStorage)
- Parses {"type":"gps","data":{...},"timestamp":...} JSON frames from iPhone
- Leaflet.js + OpenStreetMap tiles with dark CSS filter
- Live position marker (cyan pulsing dot SVG icon)
- Orange polyline trail (up to 2000 points)
- Auto-centers on first GPS fix; FOLLOW/FREE toggle; drag disables follow
- Sidebar: speed (km/h, color-coded), altitude, heading, compass rose canvas,
  h-accuracy bar (green/amber/red), coordinate display, fix count
- Scrollable trail log with timestamp + coords + speed per fix
- Exponential backoff auto-reconnect (2s→30s cap)
- CLEAR button resets trail, marker, log, fix count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:12:37 -04:00
e220797c07 Merge pull request 'feat: CAN bus watchdog and error recovery (Issue #694)' (#708) from sl-firmware/issue-694-can-watchdog into main 2026-03-20 17:59:02 -04:00
b5354e1ac0 Merge pull request 'feat: PID tuning interface via CAN/ROS2 (Issue #693)' (#707) from sl-controls/issue-693-pid-tuning into main 2026-03-20 17:58:34 -04:00
f59bc9931e feat: CAN bus watchdog and error recovery (Issue #694)
- CAN1_SCE_IRQHandler: detects bus-off/error-passive/error-warning from ESR
- can_driver_watchdog_tick(): polls ESR each cycle, auto-restarts after CAN_WDOG_RESTART_MS (200ms)
- can_wdog_t: tracks restart_count, busoff_count, errpassive_count, errwarn_count, tec, rec
- JLink TLM code 0x8F (JLINK_TLM_CAN_WDOG) with jlink_send_can_wdog_tlm()
- main.c: calls watchdog_tick() each loop, sends CAN wdog TLM at 1 Hz
- TEST_HOST: inject_esr() stub + busoff_pending flag fixes t=0 sentinel ambiguity
- test/test_can_watchdog.c: 23 unit tests, all pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:39:01 -04:00
de4d1bbe3a feat: PID tuning interface via CAN/ROS2 (Issue #693)
- Mamba (STM32): add ORIN_CAN_ID_PID_SET (0x305) handler in orin_can.c.
  Receives kp/ki/kd as uint16*100 (BE), applies to running balance loop,
  replies with FC_PID_ACK (0x405) echoing clamped gains. Gains persist in
  RAM until reboot; not saved to flash.
- Jetson: expose pid/kp, pid/ki, pid/kd as ROS2 parameters in
  can_bridge_node. Parameter changes trigger encode_pid_set_cmd() and
  send CAN frame 0x305 immediately. ACK frame 0x405 logged at DEBUG.
- mamba_protocol.py: add ORIN_CAN_ID_PID_SET / FC_PID_ACK IDs,
  PidGains dataclass, encode_pid_set_cmd(), decode_pid_ack().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:39:00 -04:00
d235c414e0 Merge pull request 'feat: SLAM map persistence for AMCL (Issue #696)' (#705) from sl-perception/issue-696-slam-map-persistence into main 2026-03-20 17:38:29 -04:00
62d7525df7 Merge pull request 'feat: VESC dual ESC mount bracket for T-slot (Issue #699)' (#704) from sl-mechanical/issue-699-vesc-mount into main 2026-03-20 17:38:27 -04:00
2b3f3584a9 Merge pull request 'feat: End-to-end CAN integration tests (Issue #695)' (#703) from sl-jetson/issue-695-can-e2e-test into main 2026-03-20 17:38:25 -04:00
7a100b2d14 Merge pull request 'feat: WebSocket bridge for CAN monitor dashboard (Issue #697)' (#702) from sl-webui/issue-697-websocket-bridge into main 2026-03-20 17:38:23 -04:00
37b646780d Merge pull request 'feat: Android BLE pairing UI for UWB tag (Issue #700)' (#701) from sl-android/issue-700-ble-pairing-ui into main 2026-03-20 17:38:22 -04:00
2d60aab79c feat: SLAM map persistence for AMCL (Issue #696)
- New map_persistence.launch.py: launches map_saver_server lifecycle node
  (nav2_map_server) + saltybot_map_saver helper node + lifecycle_manager.
  Configurable map_dir (default /mnt/nvme/saltybot/maps) and map_name.

- New map_saver_node.py: ROS2 node providing /saltybot/save_map (Trigger
  service) that calls nav2_map_server map_saver_cli. On startup logs whether
  a saved map is present. Auto-saves map on shutdown (auto_save_on_shutdown).

- New config/map_saver_params.yaml: map_saver_server params
  (save_map_timeout=5s, free/occupied thresholds, transient-local QoS).

- nav2_slam_bringup.launch.py: adds map_dir + map_name args; includes
  map_persistence.launch.py so map_saver_server runs during SLAM sessions.

- nav2_amcl_bringup.launch.py: adds map_dir arg; auto-detects saved map at
  /mnt/nvme/saltybot/maps/saltybot_map.yaml at launch time and uses it as
  the AMCL map; falls back to placeholder if not found.

- setup.py: registers map_persistence.launch.py, map_saver_params.yaml,
  map_saver_node console_scripts entry point.

- test_nav2_amcl.py: 21 new tests covering params, launch syntax,
  node service/shutdown behaviour, SLAM bringup inclusion, AMCL auto-detect.

Workflow:
  1. ros2 launch saltybot_nav2_slam nav2_slam_bringup.launch.py   (build map)
  2. ros2 service call /saltybot/save_map std_srvs/srv/Trigger {}  (save)
  3. ros2 launch saltybot_nav2_slam nav2_amcl_bringup.launch.py   (auto-loads)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:27:52 -04:00
af982bb575 feat: VESC dual ESC mount bracket (Issue #699)
3D-printable PETG cradle for FSESC 6.7 Pro Mini Dual on 2020 T-slot rail.
4x M5 T-nut mounting, open-top heatsink exposure, XT60/XT30/CAN cutouts,
floor grille and side louvre ventilation, M3 heat-set insert posts for
board retention. No supports required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:26:07 -04:00
6d59baa30e feat: End-to-end CAN integration tests (Issue #695)
Add saltybot_can_e2e_test package with 64 tests covering the full
Orin↔Mamba↔VESC CAN pipeline: drive commands, heartbeat timeout,
e-stop escalation, mode switching, and FC_VESC status broadcasts.
Tests run with plain pytest — no ROS2 or real CAN hardware required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:25:23 -04:00
1ec4d3fc58 feat: WebSocket bridge for CAN monitor dashboard (Issue #697)
rosbridge config:
- rosbridge_params.yaml: add /saltybot/barometer, /vesc/left/state,
  /vesc/right/state to topics_glob whitelist (were missing, blocked
  the CAN monitor panel from receiving data)
- can_monitor.launch.py: new lightweight launch — rosbridge only,
  whitelist scoped to the 5 CAN monitor topics, port overridable via
  launch arg (ros2 launch saltybot_bringup can_monitor.launch.py port:=9091)

can_monitor_panel.js auto-reconnect:
- Exponential backoff: 2s → 3s → 4.5s → ... → 30s cap (×1.5 factor)
- Countdown displayed in conn-label ("Retry in Xs…") during wait
- Backoff resets to 2s on successful connection
- Manual CONNECT / Enter resets backoff and cancels pending timer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:23:27 -04:00
sl-android
c6cf64217d feat: Android BLE pairing UI for UWB tag (Issue #700)
- UwbTagBleActivity: BLE scan filtered to 'UWB_TAG_XXXX' device names
- Connects to GATT service 12345678-1234-5678-1234-56789abcdef0
- Read/write JSON config char: sleep_timeout_s, display_brightness,
  tag_name, uwb_channel, ranging_interval_ms, battery_report
- Subscribes to status + battery notification characteristics
- Material Design UI with scan list, config form, and live status
- Runtime BLE permission handling for API 26+ / API 31+

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:21:45 -04:00
5906af542b Merge pull request 'feat: BME280 baro pressure & ambient temp (Issue #672)' (#673) from sl-firmware/issue-672-bme280-baro-temp into main 2026-03-18 08:27:39 -04:00
4318589496 feat: BME280 baro pressure & ambient temp driver (Issue #672)
- New baro module (include/baro.h, src/baro.c): reads BME280/BMP280
  at 1 Hz on I2C1, computes pressure altitude (ISA formula), publishes
  JLINK_TLM_BARO (0x8D) telemetry to Orin. Runs entirely on Mamba F722S
  with no Orin dependency. baro_get_alt_cm() exposes altitude to balance
  PID slope compensation.
- New JLink telemetry frame 0x8D (jlink_tlm_baro_t, 12 bytes packed):
  pressure_pa (int32), temp_x10 (int16), alt_cm (int32),
  humidity_pct_x10 (int16; -1 = BMP280/absent).
- Wire into main.c: baro_init() after bmp280_init(), baro_tick(now)
  each ms (self-rate-limits to 1 Hz).
- Unit tests (test/test_baro.c): 31 tests, all pass. Build:
    gcc -I include -I test/stubs -DTEST_HOST -lm -o /tmp/test_baro test/test_baro.c

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:25:07 -04:00
441c56b1d9 Merge pull request 'feat: Hardware button park/disarm/re-arm (Issue #682)' (#688) from sl-firmware/issue-682-hw-button into main 2026-03-18 08:21:55 -04:00
334ab9249c Merge pull request 'feat: CAN sensor remote monitor panel (Issue #681)' (#687) from sl-webui/issue-681-can-monitor into main 2026-03-18 08:10:41 -04:00
4affd6d0cb feat: Hardware button park/disarm/re-arm (Issue #682)
Add hw_button driver (PC2 active-low, 20ms debounce) with gesture detection:
- Single short press + 500ms quiet -> BTN_EVENT_PARK
- SHORT+SHORT+LONG combo (within 3s) -> BTN_EVENT_REARM_COMBO

New BALANCE_PARKED state: PID frozen, motors off, quick re-arm via button
combo without the 3-second arm interlock required from DISARMED.

FC_BTN (0x404) CAN frame sent to Orin on each event:
  event_id 1=PARKED, 2=UNPARKED, 3=UNPARK_FAILED (pitch > 20 deg)

Includes 11 unit tests (1016 assertions) exercising debounce, bounce
rejection, short/long classification, sequence detection, and timeout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:10:10 -04:00
fe979fdd1f feat: CAN sensor remote monitor panel (Issue #681)
Adds standalone vanilla JS/HTML/CSS panel for live CAN sensor monitoring:
- can_monitor_panel.html: 5-card dashboard grid with VESC L/R, Balance,
  IMU Attitude (span-2), and Barometer cards
- can_monitor_panel.css: dark-theme styles matching existing panel suite;
  bidirectional bars, live-dot flash, stat-grid, responsive layout
- can_monitor_panel.js: rosbridge subscriptions to
    /vesc/left/state + /vesc/right/state (RPM bidir bar, current gauge,
    voltage/duty/temp stats, fault badge, stale detection)
    /saltybot/imu (quaternion→Euler, angular vel, lin accel, cal badge
    from orientation_covariance[0], canvas artificial horizon + compass)
    /saltybot/balance_state (state badge, motor_cmd bidir bar, PID grid)
    /saltybot/barometer (altitude, pressure, temp)
  Auto-connect from localStorage, 1 Hz stale checker, msg rate display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:09:03 -04:00
9e8ea3c411 Merge pull request 'feat: VESC CAN health monitor (Issue #651)' (#666) from sl-jetson/issue-651-vesc-health into main 2026-03-18 08:03:32 -04:00
d57c0bd51d feat: VESC CAN health monitor (Issue #651)
New package: saltybot_vesc_health

- recovery_fsm.py: pure state machine (no ROS2/CAN deps; fully unit-tested)
  - VescHealthState: HEALTHY → DEGRADED (>500 ms) → ESTOP (>2 s) / BUS_OFF
  - VescMonitor.tick(): drives recovery sequence per VESC; startup-safe
  - VescMonitor.on_frame(): resets state on CAN frame arrival
  - VescMonitor.on_bus_off/on_bus_ok(): bus-off override + recovery
  - HealthFsm: dual-VESC wrapper aggregating both monitors

- health_monitor_node.py: ROS2 node
  - Subscribes /vesc/left/state + /vesc/right/state (JSON from vesc_telemetry)
  - Sends GET_VALUES alive frames via SocketCAN on DEGRADED state
  - Publishes /vesc/health (JSON, 10 Hz) — state, elapsed, recent faults
  - Publishes /diagnostics (DiagnosticArray, OK/WARN/ERROR per VESC)
  - Publishes /estop (JSON event) + zero /cmd_vel on e-stop trigger/clear
  - Polls ip link for bus-off state (1 Hz)
  - 200-entry fault event log included in /vesc/health

- test/test_vesc_health.py: 39 unit tests, all passing, no hardware needed
- config/vesc_health_params.yaml, launch/vesc_health.launch.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:03:19 -04:00
fdda6fe5ee Merge pull request 'feat: Nav2 AMCL integration (Issue #655)' (#664) from sl-perception/issue-655-nav2-integration into main 2026-03-18 07:57:02 -04:00
3457919c7a Merge pull request 'feat: UWB geofence speed limiting (Issue #657)' (#663) from sl-uwb/issue-657-geofence-speed-limit into main 2026-03-18 07:56:53 -04:00
cfdd74a9dc Merge pull request 'feat: VESC motor dashboard panel (Issue #653)' (#662) from sl-webui/issue-653-vesc-panel into main 2026-03-18 07:56:43 -04:00
4f3a30d871 Merge pull request 'feat: Smooth velocity controller (Issue #652)' (#661) from sl-controls/issue-652-smooth-velocity into main 2026-03-18 07:56:26 -04:00
7eb3f187e2 feat: Smooth velocity controller (Issue #652)
Adds velocity_smoother_node.py with configurable accel/decel ramps,
e-stop bypass, and optional jerk limiting. VESC driver updated to
subscribe /cmd_vel_smoothed instead of /cmd_vel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 07:56:16 -04:00
sl-android
a50dbe7e56 feat: VESC CAN telemetry MQTT relay (Issue #656)
Add vesc_mqtt_relay_node.py to saltybot_phone: subscribes to
/vesc/left/state, /vesc/right/state, /vesc/combined ROS2 topics and
publishes JSON telemetry to saltybot/phone/vesc_{left,right,combined}
MQTT topics at 5 Hz per motor.  32 unit tests, no ROS2/paho required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 07:56:02 -04:00
6561e35fd6 Merge pull request 'feat: VESC MQTT telemetry relay (Issue #656)' (#660) from sl-android/issue-656-vesc-mqtt-relay into main 2026-03-18 07:55:42 -04:00
4dc75c8a70 Merge pull request 'feat: CANable 2.0 mount (Issue #654)' (#659) from sl-mechanical/issue-654-canable-mount into main 2026-03-18 07:55:31 -04:00
4d0a377cee Merge pull request 'feat: VESC CAN odometry (Issue #646)' (#649) from sl-perception/issue-646-vesc-odometry into main 2026-03-18 07:55:17 -04:00
06101371ff fix: Use correct VESC topic names /vesc/left|right/state (Issue #670)
- VESCCANOdometryNode subscriptions now use left_state_topic/right_state_topic
  params (defaulting to /vesc/left/state and /vesc/right/state) instead of
  building /vesc/can_<id>/state from CAN IDs — those topics never existed
- Update right_can_id default: 79 → 68 (Mamba F722S architecture update)
- Update vesc_odometry_params.yaml: CAN IDs 61/79 → 56/68; add explicit
  left_state_topic and right_state_topic entries; remove stale can_N comments
- All IDs remain fully configurable via ROS2 params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 07:55:04 -04:00
cf0ceb4641 Merge pull request 'fix: Configurable VESC CAN IDs, default 56/68 (Issue #667)' (#668) from sl-controls/issue-667-configurable-can-ids into main 2026-03-18 07:50:33 -04:00
ee16bae9fb fix: Make VESC CAN IDs configurable, default 56/68 (Issue #667)
FSESC 6.7 Pro Mini Dual uses CAN IDs 56/68, not 61/79. Updates all
driver, telemetry, and odometry bridge files to use correct defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 07:50:20 -04:00
70fa404437 Merge pull request 'fix: Standardize VESC topic naming (Issue #669)' (#671) from sl-jetson/issue-669-vesc-topic-fix into main 2026-03-18 07:49:20 -04:00
c11cbaf3e6 Merge pull request 'feat: IMU mount cal, CAN telemetry, LED CAN override (Issues #680, #672, #685)' (#686) from sl-jetson/issue-681-vesc-telemetry-publish into main 2026-03-18 07:49:12 -04:00
d132b74df0 Merge pull request 'fix: Move lines=[] above lock in _read_cb() (Issue #683)' (#684) from sl-jetson/issue-683-read-cb-fix into main 2026-03-18 07:49:02 -04:00
8985934f29 Merge pull request 'fix: Bump arm pitch threshold to 20° (Issue #678)' (#679) from sl-firmware/issue-678-pitch-threshold into main 2026-03-18 07:48:49 -04:00
9ed678ca35 feat: IMU mount angle cal, CAN telemetry, LED override (Issues #680, #672, #685)
Issue #680 — IMU mount angle calibration:
- imu_cal_flash.h/.c: store pitch/roll offsets in flash sector 7
  (0x0807FF00, 64 bytes; preserves PID records across sector erase)
- mpu6000_set_mount_offset(): subtracts offsets from pitch/roll output
- mpu6000_has_mount_offset(): reports cal_status=2 to Orin
- 'O' CDC command: capture current pitch/roll → save to flash → ACK JSON
- Load offsets on boot; report in printf log

CAN telemetry correction (Tee: production has no USB to Orin):
- FC_IMU (0x402): pitch/roll/yaw/cal_status/balance_state at 50 Hz
- orin_can_broadcast_imu() rate-limited to ORIN_IMU_TLM_HZ (50 Hz)
- FC_BARO (0x403): pressure_pa/temp_x10/alt_cm at 1 Hz (Issue #672)
- orin_can_broadcast_baro() rate-limited to ORIN_BARO_TLM_HZ (1 Hz)

Issue #685 — LED CAN override:
- ORIN_CAN_ID_LED_CMD (0x304): pattern/brightness/duration_ms from Orin
- orin_can_led_override volatile state + orin_can_led_updated flag
- main.c: apply pattern to LED state machine on each LED_CMD received

Orin side:
- saltybot_can_node.py: production SocketCAN bridge — reads 0x400-0x403,
  publishes /saltybot/imu, /saltybot/balance_state, /saltybot/barometer;
  subscribes /cmd_vel → 0x301 DRIVE; /saltybot/leds → 0x304 LED_CMD;
  sends 0x300 HEARTBEAT at 5 Hz; sends 0x303 ESTOP on shutdown
- setup.py: register saltybot_can_node entry point + uart_bridge launch

Fix: re-apply --defsym __stack_end=_estack-0x1000 linker fix to branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:49:21 -04:00
06db56103f feat: Enable VESC driver telemetry publishing (Issue #681)
vesc_driver_node.py:
- Add VescState dataclass with to_dict() serialization
- Add CAN_PACKET_STATUS/STATUS_4/STATUS_5 (9/16/27) RX constants
- Add FAULT_NAMES lookup (11 VESC FW 6.6 fault codes)
- Add background CAN RX thread (_rx_loop / _dispatch_frame) that
  parses STATUS broadcast frames using struct.unpack
- Add publishers for /saltybot/vesc/left and /saltybot/vesc/right
  (std_msgs/String JSON) at configurable telemetry_rate_hz (default 10 Hz)
- Combine watchdog + publish into single timer callback
- Proper thread cleanup in destroy_node()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:49:06 -04:00
05ba557dca fix: Move lines=[] above lock in _read_cb() (Issue #683)
UnboundLocalError when _ser is None — lines was only assigned inside
the else branch. Move initialisation to function scope so the for-loop
outside the lock always has a valid list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:36:20 -04:00
0a2f336eb8 Merge pull request 'feat: Orin CAN bus bridge — CANable 2.0 (Issue #674)' (#675) from sl-jetson/issue-674-can-bus-orin into main 2026-03-17 21:41:29 -04:00
5e82878083 feat: bxCAN integration for VESC motor control and Orin comms (Issue #674)
- can_driver: add filter bank 15 (all ext IDs → FIFO1) and widen bank 14
  to accept all standard IDs; add can_driver_send_ext/std and ext/std
  frame callbacks (can_driver_set_ext_cb / can_driver_set_std_cb)
- vesc_can: VESC 29-bit extended CAN protocol driver — send RPM to IDs 56
  and 68 (FSESC 6.7 Pro Mini Dual), parse STATUS/STATUS_4/STATUS_5
  big-endian payloads, alive timeout, JLINK_TLM_VESC_STATE at 1 Hz
- orin_can: Orin↔FC standard CAN protocol — HEARTBEAT/DRIVE/MODE/ESTOP
  commands in, FC_STATUS + FC_VESC broadcast at 10 Hz
- jlink: add JLINK_TLM_VESC_STATE (0x8E), jlink_tlm_vesc_state_t (22 bytes),
  jlink_send_vesc_state_tlm()
- main: wire vesc_can_init/orin_can_init; replace can_driver_send_cmd with
  vesc_can_send_rpm; inject Orin CAN speed/steer into balance PID; add
  Orin CAN estop/clear handling; add orin_can_broadcast at 10 Hz
- test: 56-test host-side suite for vesc_can; test/stubs/stm32f7xx_hal.h
  minimal HAL stub for all future host-side tests

Safety: balance PID runs independently on Mamba — if Orin CAN link drops
(orin_can_is_alive() == false) the robot continues balancing in-place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:41:19 -04:00
92c0628c62 feat: Orin CANable 2.0 bridge for Mamba and VESC CAN bus (Issue #674)
Adds slcan setup script and saltybot_can_bridge ROS2 package implementing
full CAN bus integration between the Orin and the Mamba motor controller /
VESC motor controllers via a CANable 2.0 USB dongle (slcan interface).

- jetson/scripts/setup_can.sh: slcand-based bring-up/tear-down for slcan0
  at 500 kbps with error handling (already up, device missing, retry)
- saltybot_can_bridge/mamba_protocol.py: CAN message ID constants and
  encode/decode helpers for velocity, mode, e-stop, IMU, battery, VESC state
- saltybot_can_bridge/can_bridge_node.py: ROS2 node subscribing to /cmd_vel
  and /estop, publishing /can/imu, /can/battery, /can/vesc/{left,right}/state
  and /can/connection_status; background reader thread, watchdog zero-vel,
  auto-reconnect every 5 s on CAN error
- config/can_bridge_params.yaml: default params (slcan0, VESC IDs 56/68,
  Mamba ID 1, 0.5 s command timeout)
- test/test_can_bridge.py: 30 unit tests covering encode/decode round-trips
  and edge cases — all pass without ROS2 or CAN hardware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:40:07 -04:00
56c59f60fe fix: add __stack_end defsym for fault_handler MPU guard (Issue #678)
STM32Cube ld script provides _estack but not __stack_end. Define
__stack_end = _estack - 0x1000 (_Min_Stack_Size) via --defsym so
fault_mpu_guard_init() and fault_mem_c() can locate the stack bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:39:44 -04:00
7f67fc6abe Merge pull request 'fix: remap CAN from CAN2/PB12-13 to CAN1/PB8-9 (Issue #676)' (#677) from sl-firmware/issue-597-can-driver into main 2026-03-17 21:39:29 -04:00
ea5203b67d fix: bump arm pitch threshold 10°→20° (Issue #678)
Mamba is mounted at ~12° on the frame, causing all three arm-interlock
checks to block arming. Raise fabsf(bal.pitch_deg) < 10.0f to 20.0f
at lines 375, 512, 532 (JLink arm, RC arm rising-edge, CDC arm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:38:02 -04:00
14c80dc33c fix: remap CAN from CAN2/PB12-13 to CAN1/PB8-9 (Issue #676)
Mamba F722S MK2 does not expose PB12/PB13 externally. Waveshare CAN
module is wired to the SCL (PB8) and SDA (PB9) header pads.

Changes in can_driver_init():
- Drop __HAL_RCC_CAN2_CLK_ENABLE() — CAN1 needs no slave clock
- GPIO: GPIO_PIN_12/13 → GPIO_PIN_8/9, GPIO_AF9_CAN2 → GPIO_AF9_CAN1
- Instance: CAN2 → CAN1
- Filter bank: 14 → 0 (CAN1 master banks start at 0; bank 14 is the
  CAN2 slave-start boundary, unused here)

I2C1 is free: BME280 has been moved to I2C2 (PB10/PB11), so PB8/PB9
are available for CAN1 without any peripheral conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:31:59 -04:00
7d2d41ba9f fix: Standardize VESC topic naming to /vesc/left|right/state (Issue #669)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:18:43 -04:00
b74307c58a feat: Nav2 AMCL integration with VESC odometry + LiDAR (Issue #655)
AMCL-based autonomous navigation on pre-built static maps, wired to
VESC CAN differential-drive odometry (/odom, Issue #646) and RPLiDAR
(/scan) as the primary sensor sources.

New files (saltybot_nav2_slam):
- config/amcl_nav2_params.yaml — complete Nav2 + AMCL parameter file
  with inline global/local costmap configs (required by nav2_bringup):
  · AMCL: DifferentialMotionModel, 500–3000 particles, z-weights=1.0,
    odom_frame=/odom, scan_topic=/scan
  · Global costmap: static_layer + obstacle_layer (LiDAR) +
    inflation_layer (0.55m radius)
  · Local costmap: 4m rolling window, obstacle_layer (LiDAR) +
    inflation_layer, global_frame=odom
  · DWB controller: 1.0 m/s max, diff-drive constrained (vy=0)
  · NavFn A* planner
  · Recovery: spin + backup + wait
  · Lifecycle managers for localization and navigation
- launch/nav2_amcl_bringup.launch.py — orchestrates:
  1. sensors.launch.py (RealSense + RPLIDAR, conditional)
  2. odometry_bridge.launch.py (VESC CAN → /odom)
  3. nav2_bringup localization_launch.py (map_server + AMCL)
  4. nav2_bringup navigation_launch.py (full nav stack)
  Exposes: map, use_sim_time, autostart, params_file, include_sensors
- maps/saltybot_map.yaml — placeholder map descriptor (0.05m/cell)
- maps/saltybot_map.pgm — 200×200 P5 PGM, all free space (10m×10m)
- test/test_nav2_amcl.py — 38 unit tests (no ROS2 required):
  params structure, z-weight sum, costmap layers, DWB/NavFn validity,
  recovery behaviors, PGM format, launch file syntax checks

Updated:
- saltybot_bringup/launch/nav2.launch.py — adds nav_mode argument:
  nav_mode:=slam (default, existing RTAB-Map behaviour unchanged)
  nav_mode:=amcl (new, delegates to nav2_amcl_bringup.launch.py)
- saltybot_nav2_slam/setup.py — installs new launch, config, maps
- saltybot_nav2_slam/package.xml — adds nav2_amcl, nav2_map_server,
  nav2_behaviors, dwb_core, nav2_navfn_planner exec_depends

All 58 tests pass (38 new + 20 from Issue #646).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:39:08 -04:00
sl-uwb
9d2b19104f feat: UWB geofence speed limiting (Issue #657)
Add saltybot_uwb_geofence ROS2 package — Jetson-side node that subscribes
to /saltybot/pose/authoritative (UWB+IMU fused PoseWithCovarianceStamped),
enforces configurable polygon speed-limit zones (YAML), and publishes
speed-limited /cmd_vel_limited with smooth ramp transitions.

Emergency boundary: if robot exits outer polygon, cmd_vel is zeroed and
/saltybot/geofence_violation (Bool) is latched True, triggering the
existing e-stop cascade.  Publishes /saltybot/geofence/status (JSON).

Pure-geometry helpers (zone_checker.py) have no ROS2 dependency;
35 unit tests pass (pytest).  ESP32 UWB firmware untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:36:37 -04:00
89f892e5ef feat: VESC motor dashboard panel (Issue #653)
Standalone panel ui/vesc_panel.{html,js,css} with live CAN telemetry
via rosbridge. Subscribes to /vesc/left/state, /vesc/right/state
(std_msgs/String JSON) and /vesc/combined for battery voltage.

Features:
- Canvas arc gauge per motor showing RPM + direction (FWD/REV/STOP)
- Current draw bar (motor + input), duty cycle bar, temperature bars
- FET and motor temperature boxes with warn/crit colour coding
- Sparkline charts for RPM and current (last 60 s, 120 samples)
- Battery card: voltage, total draw, both RPMs, SOC progress bar
- Colour-coded health: green/amber/red at configurable thresholds
- E-stop button: publishes zero /cmd_vel + /saltybot/emergency event
- Stale detection (2 s timeout → OFFLINE state)
- Hz counter + last-stamp display in header
- Mobile-responsive layout (single-column below 640 px)
- WS URL persisted in localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:35:35 -04:00
sl-android
289185e6cf feat: VESC CAN telemetry MQTT relay (Issue #656)
Add vesc_mqtt_relay_node.py to saltybot_phone: subscribes to
/vesc/left/state, /vesc/right/state, /vesc/combined ROS2 topics and
publishes JSON telemetry to saltybot/phone/vesc_{left,right,combined}
MQTT topics at 5 Hz per motor.  32 unit tests, no ROS2/paho required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:33:59 -04:00
4f81571dd3 feat: CANable 2.0 mount for T-slot rail (Issue #654)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:33:06 -04:00
Sebastien Vayrette
d9b4b10b90 Merge remote-tracking branch 'origin/sl-perception/issue-646-vesc-odometry' 2026-03-17 11:27:45 -04:00
Sebastien Vayrette
a96fd91ed7 Merge remote-tracking branch 'origin/sl-firmware/issue-645-vesc-telemetry' 2026-03-17 11:27:36 -04:00
Sebastien Vayrette
bf8df6af8f Merge remote-tracking branch 'origin/sl-controls/issue-644-vesc-can-driver' 2026-03-17 11:27:26 -04:00
d8b25bad77 feat: VESC CAN odometry for nav2 (Issue #646)
Replace single-motor vesc_odometry_bridge with dual-CAN differential
drive odometry for left (CAN 61) and right (CAN 79) VESC motors.

New files:
- diff_drive_odom.py: pure-Python kinematics (eRPM→wheel vel, exact arc
  integration, heading wrap), no ROS deps, fully unit-tested
- test/test_vesc_odometry.py: 20 unit tests (straight, arc, spin,
  invert_right, guard conditions) — all pass
- config/vesc_odometry_params.yaml: configurable wheel_radius,
  wheel_separation, motor_poles, invert_right, covariance tuning

Updated:
- vesc_odometry_bridge.py: rewritten as VESCCANOdometryNode; subscribes
  to /vesc/can_61/state and /vesc/can_79/state (std_msgs/String JSON);
  publishes /odom and /saltybot/wheel_odom (nav_msgs/Odometry) + TF
  odom→base_link with proper 6×6 covariance matrices
- odometry_bridge.launch.py: updated to launch vesc_can_odometry with
  vesc_odometry_params.yaml
- setup.py: added vesc_can_odometry entry point + config install
- pose_fusion_node.py: added optional wheel_odom_topic subscriber that
  feeds DiffDriveOdometry velocities into EKF via update_vo_velocity
- pose_fusion_params.yaml: added use_wheel_odom, wheel_odom_topic,
  sigma_wheel_vel_m_s, sigma_wheel_omega_r_s parameters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:54:19 -04:00
b2c9f368f6 feat: VESC CAN telemetry for dual motors (Issue #645)
New saltybot_vesc_telemetry ROS2 package — SocketCAN (python-can, can0)
telemetry for dual FSESC 6.7 Pro (FW 6.6) on CAN IDs 61 (left) and 79 (right).

- vesc_can_protocol.py: STATUS/STATUS_4/STATUS_5 frame parsers, VescState
  dataclass, GET_VALUES request builder (CAN_PACKET_PROCESS_SHORT_BUFFER)
- vesc_telemetry_node.py: ROS2 node; background CAN RX thread; publishes
  /vesc/left/state, /vesc/right/state, /vesc/combined (JSON String msgs),
  /diagnostics (DiagnosticArray); overcurrent/overtemp/fault alerting;
  configurable poll rate 10-50 Hz (default 20 Hz)
- test_vesc_telemetry.py: 31 unit tests, all passing (no ROS/CAN required)
- config/vesc_telemetry_params.yaml, launch file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:53:09 -04:00
a506989af6 feat: CANable 2.0 bringup with udev rule and systemd service (Issue #643)
- udev: 70-canable.rules — gs_usb VID/PID 1d50:606f, names iface can0 and brings it up at 500 kbps on plug-in
- systemd: can-bringup.service — oneshot service bound to sys-subsystem-net-devices-can0.device
- scripts: can_setup.sh — manual up/down/verify helper; candump verify for VESC IDs 61 (0x3D) and 79 (0x4F)
- install_systemd.sh updated to install can-bringup.service and all udev rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:49:21 -04:00
1d87899270 feat: VESC SocketCAN dual-motor driver IDs 61/79 (Issue #644)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:47:57 -04:00
0fcad75cb4 Merge pull request 'feat: Encoder odometry and wheel speed feedback (Issue #632)' (#642) from sl-controls/issue-632-encoder-odom into main 2026-03-15 17:29:54 -04:00
5aadf4b5c8 Merge pull request 'feat: Jetson Orin system monitor ROS2 node (Issue #631)' (#640) from sl-jetson/issue-631-system-monitor into main 2026-03-15 17:29:50 -04:00
5f0affcd79 feat: Jetson Orin system monitor ROS2 node (Issue #631)
New package saltybot_system_monitor:
- jetson_stats.py: pure-Python data layer (JetsonStats, CpuCore,
  TegrastatsParser, JtopReader, TegrastatsReader, MockReader,
  AlertChecker, AlertThresholds) — no ROS2 dependency
- system_monitor_node.py: ROS2 node publishing /saltybot/system/stats
  (JSON) and /saltybot/diagnostics (DiagnosticArray) at 1 Hz
- Alerts: CPU/GPU >85% WARN (+10% ERROR), temp >80°C, disk/RAM >90%,
  power >30 W; each alert produces a DiagnosticStatus entry
- Stats source priority: jtop > tegrastats > mock (auto-detected)
- config/system_monitor.yaml: all thresholds and rate tunable via params
- launch/system_monitor.launch.py: single-node launch with config arg
- test/test_system_monitor.py: 50+ pytest tests, ROS2-free

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:36:04 -04:00
779f9d00e2 feat: Encoder odometry and wheel speed feedback (Issue #632)
- TIM2 (32-bit) left encoder, TIM3 (16-bit) right encoder in mode 3
- RPM calculation with int16 clamp; 16-bit wrap handled via signed delta
- Differential-drive odometry: x/y/theta Euler-forward integration
- Flash config (sector 7, 0x0807FF00) for ticks_per_rev/wheel_diam/base
- JLINK_TLM_ODOM (0x8C) at 50 Hz: rpm_l/r, x_mm, y_mm, theta_cdeg, speed_mmps
- 75/75 unit tests passing (TEST_HOST build)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:34:38 -04:00
4c7fa938a5 Merge pull request 'feat: UWB accuracy analyzer (Issue #634)' (#641) from sl-uwb/issue-634-uwb-logger into main 2026-03-15 16:30:16 -04:00
45332f1a8b Merge pull request 'feat: UART command protocol for Jetson-STM32 (Issue #629)' (#639) from sl-firmware/issue-629-uart-protocol into main 2026-03-15 16:30:09 -04:00
af46b15391 Merge pull request 'feat: ArUco docking detection (Issue #627)' (#638) from sl-perception/issue-627-aruco-docking into main 2026-03-15 16:30:04 -04:00
e1d605dba7 Merge pull request 'feat: WebUI main dashboard (Issue #630)' (#637) from sl-webui/issue-630-main-dashboard into main 2026-03-15 16:30:00 -04:00
c8c8794daa Merge pull request 'feat: Termux voice command interface (Issue #633)' (#636) from sl-android/issue-633-voice-commands into main 2026-03-15 16:29:56 -04:00
b5862ef529 Merge pull request 'feat: Cable management tray (Issue #628)' (#635) from sl-mechanical/issue-628-cable-tray into main 2026-03-15 16:29:52 -04:00
sl-uwb
343e53081a feat: UWB position logger and accuracy analyzer (Issue #634)
saltybot_uwb_logger_msgs (new package):
- AccuracyReport.msg: n_samples, mean/bias/std (x,y,2D), CEP50, CEP95,
  RMSE, max_error, per-anchor range stats, test_id, duration_s
- StartAccuracyTest.srv: request (truth_x/y_m, n_samples, timeout_s,
  test_id) → response (success, message, test_id)

saltybot_uwb_logger (new package):
- accuracy_stats.py: compute_stats() + RangeAccum — pure numpy, no ROS2
  CEP50/CEP95 = 50th/95th percentile of 2-D error; bias, std, RMSE, max
- logger_node.py: /uwb_logger ROS2 node
  Subscribes:
    /saltybot/pose/fused  → fused_pose_<DATE>.csv (ts, x, y, heading)
    /saltybot/uwb/pose    → uwb_pose_<DATE>.csv   (ts, x, y)
    /uwb/ranges           → uwb_ranges_<DATE>.csv (ts, anchor_id, range_m,
                                                   raw_mm, rssi, tag_id)
  Service /saltybot/uwb/start_accuracy_test:
    Collects N fused-pose samples at known (truth_x, truth_y) in background
    thread. On completion or timeout: publishes AccuracyReport on
    /saltybot/uwb/accuracy_report + writes accuracy_<test_id>.json.
    Per-anchor range stats included. CSV flushed every 5 s.

Tests: 16/16 passing (test/test_accuracy_stats.py, no ROS2/hardware)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:21 -04:00
602fbc6ab3 feat: UART command protocol for Jetson-STM32 (Issue #629)
Implements binary command protocol on UART5 (PC12/PD2) at 115200 baud
for Jetson→STM32 communication. Frame: STX+LEN+CMD+PAYLOAD+CRC8+ETX.

Commands: SET_VELOCITY (RPM direct to CAN), GET_STATUS, SET_PID, ESTOP,
CLEAR_ESTOP. DMA1_Stream0_Channel4 circular 256-byte RX ring. ACK/NACK
inline; STATUS pushed at 10 Hz. Heartbeat timeout 500 ms (UART_PROT_HB_TIMEOUT_MS).

NOTE: Spec requested USART1 @ 115200; USART1 occupied by JLink @ 921600.
Implemented on UART5 instead; note in code comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:41:00 -04:00
1fd935b87e feat: ArUco marker detection for docking (Issue #627)
New package saltybot_aruco_detect — DICT_4X4_50 ArUco detection from
RealSense D435i RGB, pose estimation, PoseArray + dock target output.

aruco_math.py (pure Python, no ROS2): rot_mat_to_quat (Shepperd),
  rvec_to_quat (Rodrigues + cv2 fallback), tvec_distance, tvec_yaw_rad,
  MarkerPose dataclass with lazy-cached distance_m/yaw_rad/lateral_m/quat.

aruco_detect_node.py (ROS2 node 'aruco_detect'):
  Subscribes: /camera/color/image_raw (30Hz BGR8) + /camera/color/camera_info.
  Converts to greyscale, cv2.aruco.ArucoDetector.detectMarkers().
  estimatePoseSingleMarkers (legacy API) with solvePnP(IPPE_SQUARE) fallback.
  Dock target: closest marker in dock_marker_ids (default=[42], empty=any),
    filtered to max_dock_range_m (3.0m).
  Publishes: /saltybot/aruco/markers (PoseArray — all detected, camera frame),
    /saltybot/aruco/dock_target (PoseStamped — closest dock candidate,
    position.z=forward, position.x=lateral), /saltybot/aruco/viz (MarkerArray
    — SPHERE + TEXT per marker, dock in red), /saltybot/aruco/status (JSON
    10Hz — detected_count, dock_distance_m, dock_yaw_deg, dock_lateral_m).
  Optional debug image with drawDetectedMarkers + drawFrameAxes.
  corner_refinement=CORNER_REFINE_SUBPIX.

config/aruco_detect_params.yaml, launch/aruco_detect.launch.py.
test/test_aruco_math.py: 22 unit tests (rotation/quat math, distance,
  yaw sign/magnitude, MarkerPose accessors + caching).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:37:22 -04:00
b6c6dbd838 feat: WebUI main dashboard with panel launcher (Issue #630)
Replaces ui/index.html (old USB-serial HUD) with a full rosbridge
dashboard. Adds ui/dashboard.{css,js}.

Top bar:
- Robot name +  SALTYBOT logo
- Live battery % + voltage with fill bar (4S LiPo: 12.0V–16.8V)
- Safety state from /saltybot/safety_zone/status (GREEN/AMBER/RED)
- E-stop state display
- Drive mode display
- ROS uptime counter
- rosbridge WS input + CONNECT button

Panel grid (auto-fill responsive):
- MAP VIEW (#587) — /saltybot/pose/fused liveness dot
- GAMEPAD TELEOP (#598) — /cmd_vel activity indicator
- DIAGNOSTICS (#562) — /diagnostics liveness dot
- EVENT LOG (#576) — /rosout liveness dot
- SETTINGS (#614) — param service (config state, no topic)
- GIMBAL (#551) — /gimbal/state liveness dot

Each card shows: icon, title, issue #, description, topic chips,
and a LIVE/IDLE/OFFLINE status badge updated every second. Cards
open the linked standalone panel in the same tab.

Auto-detect rosbridge:
- Probes: page hostname:9090, localhost:9090, saltybot.local:9090
- Progress dots per candidate (trying/ok/fail)
- Falls back to manual URL entry
- Saves last successful URL to localStorage

Bottom bar:
-  E-STOP button (latches, publishes zero Twist to /cmd_vel)
  Space bar shortcut from dashboard
- RESUME button
- Drive mode switcher: MANUAL / AUTO / FOLLOW / DOCK
  (publishes to /saltybot/drive_mode std_msgs/String)
- Session timer (HH:MM:SS since page load)

Info strip: rosbridge URL · msg rate · latency (5s ping via
/rosapi/get_time) · robot IP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:35:56 -04:00
sl-android
26bf4ab8d3 feat: Add Termux voice command interface (Issue #633)
phone/voice_cmd.py — listens via termux-speech-to-text, parses commands
(go forward/back, turn left/right, stop, e-stop, go to waypoint, speed
up/down, status) and publishes structured JSON to saltybot/phone/voice_cmd.
TTS confirmation via termux-tts-speak. Manual text fallback via --text flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:35:27 -04:00
cb802ee76f feat: Cable management tray (Issue #628) 2026-03-15 14:33:49 -04:00
0e8758e9e1 Merge pull request 'feat: Battery voltage telemetry + LVC (Issue #613)' (#626) from sl-firmware/issue-613-battery-voltage into main 2026-03-15 13:29:32 -04:00
7785a16bff feat: Battery voltage telemetry and LVC (Issue #613)
- Add include/lvc.h + src/lvc.c: 3-stage low voltage cutoff state machine
  WARNING  21.0V: MELODY_LOW_BATTERY buzzer, full motor power
  CRITICAL 19.8V: double-beep every 10s, 50% motor power scaling
  CUTOFF   18.6V: MELODY_ERROR one-shot, motors disabled + latched
  200mV hysteresis on recovery; CUTOFF latched until reboot
- Add JLINK_TLM_LVC (0x8B, 4 bytes): voltage_mv, percent, protection_state
  jlink_send_lvc_tlm() frame encoder in jlink.c
- Wire into main.c:
  lvc_init() at startup; lvc_tick() each 1kHz loop tick
  lvc_is_cutoff() triggers safety_arm_cancel + balance_disarm + motor_driver_estop
  lvc_get_power_scale() applied to ESC speed command (100/50/0%)
  1Hz JLINK_TLM_LVC telemetry with fuel-gauge percent field
- Add LVC thresholds to config.h (LVC_WARNING/CRITICAL/CUTOFF/HYSTERESIS_MV)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 11:04:38 -04:00
68568b2b66 Merge pull request 'feat: WebUI settings panel (Issue #614)' (#622) from sl-webui/issue-614-settings-panel into main 2026-03-15 11:03:04 -04:00
38df5b4ebb Merge pull request 'feat: GPS waypoint logger (Issue #617)' (#620) from sl-android/issue-617-waypoint-logger into main 2026-03-15 11:02:58 -04:00
fea550c851 Merge pull request 'feat: ROS2 bag recording manager (Issue #615)' (#625) from sl-jetson/issue-615-bag-recorder into main 2026-03-15 11:02:37 -04:00
13b17a11e1 Merge pull request 'feat: Steering PID controller (Issue #616)' (#624) from sl-controls/issue-616-steering-pid into main 2026-03-15 11:02:33 -04:00
96d13052b4 Merge pull request 'feat: RealSense obstacle detection (Issue #611)' (#623) from sl-perception/issue-611-obstacle-detect into main 2026-03-15 11:02:29 -04:00
a01fa091d4 Merge pull request 'feat: ESP-NOW to ROS2 serial relay node (Issue #618)' (#621) from sl-uwb/issue-618-espnow-relay into main 2026-03-15 11:02:21 -04:00
62aab7164e Merge pull request 'feat: Jetson Orin Nano mount bracket (Issue #612)' (#619) from sl-mechanical/issue-612-jetson-mount into main 2026-03-15 11:02:14 -04:00
7e12dab4ae feat: ROS2 bag recording manager (Issue #615)
Upgrades saltybot_bag_recorder (Issue #488) with:

- Motion-triggered auto-record: subscribes /cmd_vel, starts on non-zero
  velocity, stops after 30s idle timeout (configurable)
- Auto-split at 1 GB or 10 min via subprocess restart
- USB/NVMe storage selection: ordered priority list, picks first path
  with >= 2 GB free (/media/usb0 -> /media/usb1 -> /mnt/nvme -> ~/bags)
- Disk monitoring: warns at 70%, triggers cleanup of bags >7 days at 80%
- JSON status on /saltybot/bag_recorder/status at 1 Hz
- Services: /saltybot/bag_recorder/{start,stop,split}
  (legacy /saltybot/{start,stop}_recording kept for compatibility)
- bag_policy.py: pure-Python MotionState, DiskInfo, StorageSelector,
  BagPolicy — ROS2-free, fully unit-testable
- 76 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:12:40 -04:00
1e69337ffd feat: Steering PID for differential drive (Issue #616)
Closed-loop yaw-rate controller that converts Jetson Twist.angular.z
to a differential wheel speed offset using IMU gyro Z as feedback.

- include/steering_pid.h + src/steering_pid.c: PID with anti-windup
  (integral clamped to ±200 counts) and rate limiter (10 counts/ms
  max output change) to protect balance PID from sudden steering steps.
  JLINK_TLM_STEERING (0x8A) telemetry at 10 Hz.
- include/mpu6000.h + src/mpu6000.c: expose yaw_rate (board_gz) in
  IMUData so callers have direct bias-corrected gyro Z feedback.
- include/jlink.h + src/jlink.c: add JLINK_TLM_STEERING (0x8A),
  jlink_tlm_steering_t (8 bytes), jlink_send_steering_tlm().
- test/test_steering_pid.c: 78 unit tests (host build with gcc),
  all passing.

Usage (main loop):
  steering_pid_set_target(&s, jlink_state.steer * STEER_OMEGA_SCALE);
  int16_t steer_out = steering_pid_update(&s, imu.yaw_rate, dt);
  motor_driver_update(&motor, balance_cmd, steer_out, now_ms);

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:11:05 -04:00
82ad626a94 feat: RealSense depth obstacle detection (Issue #611)
New package saltybot_obstacle_detect — RANSAC ground plane fitting on
D435i depth images with 2D grid BFS obstacle clustering.

ground_plane.py (pure Python + numpy):
  fit_ground_plane(pts, n_iter=50, inlier_thresh_m=0.06): RANSAC over 3D
  point cloud in camera optical frame (+Z forward). Samples 3 points, fits
  plane via cross-product, counts inliers, refines via SVD on best inlier
  set. Orients normal toward -Y (upward in world). Returns (normal, d).
  height_above_plane(pts, plane): signed h = d - n·p (h>0 = above ground).
  obstacle_mask(pts, plane, min_h, max_h): min_obstacle_h_m < h < max_h.
  ground_mask(pts, plane, thresh): inlier classification.

obstacle_clusterer.py (pure Python + numpy):
  cluster_obstacles(pts, heights, cell_m=0.30, min_pts=5): projects
  obstacle 3D points onto (X,Z) bird's-eye plane, discretises into grid
  cells, runs 4-connected BFS flood-fill, returns ObstacleCluster list
  sorted by forward distance. ObstacleCluster: centroid(3), radius_m,
  height_m, n_pts + distance_m/lateral_m properties.

obstacle_detect_node.py (ROS2 node 'obstacle_detect'):
  - Subscribes: /camera/depth/camera_info (latched, once),
    /camera/depth/image_rect_raw (BEST_EFFORT, 30Hz float32 depth).
  - Pipeline: stride downsample (default 8x → 80x60) → back-project to
    3D → RANSAC ground plane (temporally blended α=0.3) → obstacle mask
    (min_h=0.05m, max_h=0.80m) → BFS clustering → alert classification.
  - Publishes:
      /saltybot/obstacles (MarkerArray): SPHERE markers colour-coded
        DANGER(red)/WARN(yellow)/CLEAR(green) + distance TEXT labels.
      /saltybot/obstacles/cloud (PointCloud2): xyz float32 non-ground pts.
      /saltybot/obstacles/alert (String JSON): alert_level, closest_m,
        obstacle_count, per-obstacle {x,y,z,radius_m,height_m,level}.
  - Safety zone integration (depth_estop_enabled=false by default):
      DANGER → zero Twist to depth_estop_topic (/cmd_vel_input) feeds
      into safety_zone's cmd_vel chain for independent depth e-stop.

config/obstacle_detect_params.yaml: all tuneable parameters with comments.
launch/obstacle_detect.launch.py: single node with params_file arg.
test/test_ground_plane.py: 10 unit tests (RANSAC correctness, normal
  orientation, height computation, inlier/obstacle classification).
test/test_obstacle_clusterer.py: 8 unit tests (single/dual cluster,
  distance sort, empty, min_pts filter, centroid accuracy, range clip).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:09:23 -04:00
921eaba8b3 feat: WebUI settings and configuration panel (Issue #614)
Standalone ui/settings_panel.{html,js,css} — no build step.

Sections / tabs:
- PID: balance_controller (Kp/Ki/Kd/i_clamp/rate),
  adaptive_pid (kp/ki/kd per load profile, output bounds)
- Speed: tank_driver (max_linear_vel, max_angular_vel, slip_factor),
  smooth_velocity_controller (accel/decel limits),
  battery_speed_limiter (speed factors)
- Safety: safety_zone (danger_range_m, warn_range_m, forward_arc_deg,
  debounce, min_valid_range, publish_rate),
  power_supervisor_node (battery % thresholds, speed factors),
  lidar_avoidance (e-stop distance, safety zone sizes)
- Sensors: boolean toggles (estop_all_arcs, lidar_enabled, uwb_enabled),
  uwb_imu_fusion weights and publish rate
- System: live /diagnostics subscriber (CPU/GPU/board/motor temps,
  RAM/GPU/disk usage, WiFi RSSI+latency, MQTT status, last-update),
  /rosapi/nodes node list

ROS2 parameter services (rcl_interfaces/srv/GetParameters +
SetParameters) via rosbridge WebSocket. Each section has independent
↓ LOAD (get_parameters) and ↑ APPLY (set_parameters) buttons with
success/error status feedback.

Presets: save/load/delete named snapshots of all values to
localStorage. Reset-to-defaults button restores built-in defaults.
Changed fields highlighted in amber (slider thumb + input border).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:08:47 -04:00
sl-uwb
65e0009118 feat: ESP-NOW to ROS2 serial relay (Issue #618)
New ROS2 package saltybot_uwb_espnow_relay:
- packet.py: EspNowPacket dataclass + FrameReader stateful decoder
  - Parses 20-byte ESP-NOW packets: MAGIC, tag_id, msg_type, anchor_id,
    range_mm (int32 LE), rssi_dbm (float32), timestamp_ms, battery_pct,
    flags (bit0=estop), seq_num
  - Serial framing: STX(0x02) + LEN(0x14) + DATA[20] + XOR-CRC(1)
  - Sync recovery: re-hunts STX after bad LEN or CRC; byte-by-byte capable
- relay_node.py: /espnow_relay ROS2 node
  - Reads from USB serial in background thread (auto-reconnects on error)
  - MSG_RANGE (0x10): publishes UwbRange on /uwb/espnow/ranges
  - MSG_ESTOP (0x20): publishes std_msgs/Bool on /uwb/espnow/estop
    and /saltybot/estop (latched True for estop_latch_s after last packet)
  - MSG_HEARTBEAT (0x30): publishes EspNowHeartbeat on /uwb/espnow/heartbeat
  - Range validity gating: min_range_m / max_range_m params
- 16/16 unit tests passing (test/test_packet.py, no ROS2/hardware needed)

saltybot_uwb_msgs: add EspNowHeartbeat.msg
  (tag_id, battery_pct, seq_num, timestamp_ms + std_msgs/Header)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:08:19 -04:00
sl-android
9b1f3ddaf0 feat: GPS waypoint logger and route planner (Issue #617)
Add phone/waypoint_logger.py — interactive Termux CLI for recording,
managing, and publishing GPS waypoints:

GPS acquisition
  - termux-location with gps/network/passive provider selection
  - Falls back to network provider on GPS timeout
  - Optional --live-gps flag: subscribes to saltybot/phone/gps MQTT
    topic (sensor_dashboard.py stream) to avoid redundant GPS calls

Waypoint operations
  - Record: acquires GPS fix, prompts for name + tags, appends to route
  - List: table with lat/lon/alt/accuracy/tags + inter-waypoint
    distance (haversine) and bearing (8-point compass)
  - Delete: by index with confirmation prompt
  - Clear: entire route with confirmation
  - Rename: route name

Persistence
  - Routes saved as JSON to ~/saltybot_route.json (configurable)
  - Auto-loads on startup; survives session restarts

MQTT publish (saltybot/phone/route, QoS 1, retained)
  - Full waypoint list with metadata
  - nav2_poses array: flat-earth x/y (metres from origin),
    quaternion yaw facing next waypoint (last faces prev)
  - Compatible with Nav2 FollowWaypoints action input

Geo maths
  - haversine_m(): great-circle distance
  - bearing_deg(): initial bearing with 8-point compass label
  - flat_earth_xy(): ENU metres for Nav2 pose export (<1% error <100km)

Flags: --broker, --port, --file, --route, --provider, --live-gps,
       --no-mqtt, --debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:05:57 -04:00
837c42a00d feat: Jetson Orin Nano mount bracket (Issue #612) 2026-03-15 10:04:37 -04:00
c0bb4f6276 Merge pull request 'feat: CAN bus driver for BLDC motor controllers (Issue #597)' (#610) from sl-firmware/issue-597-can-driver into main 2026-03-14 16:27:36 -04:00
2996d18ace feat: CAN bus driver for BLDC motor controllers (Issue #597)
- Add can_driver.h / can_driver.c: CAN2 on PB12/PB13 (AF9) at 500 kbps
  APB1=54 MHz, PSC=6, BS1=13tq, BS2=4tq, SJW=1tq → 18tq/bit, SP 77.8%
  Filter bank 14 (SlaveStartFilterBank=14); 32-bit mask; FIFO0
  Accept std IDs 0x200–0x21F (left/right feedback frames)
  TX: velocity+torque cmd (0x100+nid, DLC=4) at 100 Hz via main loop
  RX: velocity/current/position/temp/fault feedback (0x200+nid, DLC=8)
  AutoBusOff=ENABLE for HW recovery; can_driver_process() drains FIFO0
- Add JLINK_TLM_CAN_STATS (0x89, 16 bytes) + JLINK_CMD_CAN_STATS_GET (0x10)
  Also add JLINK_TLM_SLOPE (0x88) + jlink_tlm_slope_t missing from Issue #600
- Wire into main.c: init after jlink_init; 100Hz TX loop (differential drive
  speed_rpm ± steer_rpm/2); CAN enable follows arm state; 1Hz stats telemetry
- Add CAN_RPM_SCALE=10 and CAN_TLM_HZ=1 to config.h

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:58:13 -04:00
bb5eff1382 Merge pull request 'feat: MQTT-to-ROS2 phone sensor bridge (Issue #601)' (#605) from sl-android/issue-601-mqtt-ros2-bridge into main 2026-03-14 15:55:22 -04:00
8b1d6483cf Merge pull request 'feat: Slope tilt compensation (Issue #600)' (#609) from sl-controls/issue-600-slope-compensation into main 2026-03-14 15:55:01 -04:00
6c00d6a321 Merge pull request 'feat: UWB anchor auto-calibration via inter-anchor ranging + MDS (Issue #602)' (#608) from sl-uwb/issue-602-anchor-calibration into main 2026-03-14 15:54:56 -04:00
2460ba27c7 Merge pull request 'feat: Nav2 with UWB localization (Issue #599)' (#607) from sl-jetson/issue-599-nav2-uwb into main 2026-03-14 15:54:52 -04:00
2367e08140 Merge pull request 'feat: Multi-sensor pose fusion (Issue #595)' (#606) from sl-perception/issue-595-pose-fusion into main 2026-03-14 15:54:48 -04:00
f188997192 Merge pull request 'feat: RPLIDAR A1 mount bracket (Issue #596)' (#604) from sl-mechanical/issue-596-rplidar-mount into main 2026-03-14 15:54:40 -04:00
7e5f673f7d Merge pull request 'feat: WebUI gamepad teleop panel (Issue #598)' (#603) from sl-webui/issue-598-gamepad-teleop into main 2026-03-14 15:54:36 -04:00
be4966b01d feat: Tilt compensation for slopes (Issue #600)
Adds a slow-adapting terrain slope estimator (IIR tau=5s) that decouples
the robot's balance offset from genuine ground incline.  The balance
controller subtracts the slope estimate from measured pitch so the PID
balances around the slope surface rather than absolute vertical.

- include/slope_estimator.h + src/slope_estimator.c: first-order IIR
  filter clamped to ±15°; JLINK_TLM_SLOPE (0x88) telemetry at 1 Hz
- include/jlink.h + src/jlink.c: add JLINK_TLM_SLOPE (0x88),
  jlink_tlm_slope_t (4 bytes), jlink_send_slope_tlm()
- include/balance.h + src/balance.c: integrate slope_estimator into
  balance_t; update, reset on tilt-fault and disarm
- test/test_slope_estimator.c: 35 unit tests, all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:07:05 -04:00
sl-uwb
82cc223fb8 feat: Add AT+PEER_RANGE= command for inter-anchor calibration (Issue #602)
- peer_range_once(): DS-TWR initiator role toward a peer anchor
  (POLL → RESP → FINAL, one-sided range estimate Ra - Da/2)
- AT+PEER_RANGE=<id>: returns +PEER_RANGE:<my>,<peer>,<mm>,<rssi>
  or +PEER_RANGE:ERR,<peer>,TIMEOUT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:06:29 -04:00
5f03e4cbef feat: Tilt compensation for slopes (Issue #600)
Adds a slow-adapting terrain slope estimator (IIR tau=5s) that decouples
the robot's balance offset from genuine ground incline.  The balance
controller subtracts the slope estimate from measured pitch so the PID
balances around the slope surface rather than absolute vertical.

- include/slope_estimator.h + src/slope_estimator.c: first-order IIR
  filter clamped to ±15°; JLINK_TLM_SLOPE (0x88) telemetry at 1 Hz
- include/jlink.h + src/jlink.c: add JLINK_TLM_SLOPE (0x88),
  jlink_tlm_slope_t (4 bytes), jlink_send_slope_tlm()
- include/balance.h + src/balance.c: integrate slope_estimator into
  balance_t; update, reset on tilt-fault and disarm
- test/test_slope_estimator.c: 35 unit tests, all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:04:58 -04:00
sl-uwb
587ca8a98e feat: UWB anchor auto-calibration via inter-anchor ranging + MDS (Issue #602)
Anchor firmware (esp32/uwb_anchor/src/main.cpp):
- Add peer_range_once(peer_id) — DS-TWR initiator role toward a peer anchor
- Add AT+PEER_RANGE=<id> command: triggers inter-anchor ranging and returns
  +PEER_RANGE:<my_id>,<peer_id>,<range_mm>,<rssi_dbm> (or ERR,TIMEOUT)

ROS2 package saltybot_uwb_calibration_msgs:
- CalibrateAnchors.srv: request (anchor_ids[], n_samples) →
  response (positions_x/y/z[], residual_rms_m, anchor_positions_json)

ROS2 package saltybot_uwb_calibration:
- mds_math.py: classical MDS (double-centred D², eigendecomposition),
  anchor_frame_align() to fix anchor-0 at origin / anchor-1 on +X
- calibration_node.py: /saltybot/uwb/calibrate_anchors service —
  opens anchor serial ports, rounds-robin AT+PEER_RANGE= for all pairs,
  builds N×N distance matrix, runs MDS, returns JSON anchor positions
- 12/12 unit tests passing (test/test_mds_math.py)
- Supports ≥ 4 anchors; 5× averaged ranging per pair by default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:03:53 -04:00
40b0917c33 feat: Nav2 integration with UWB localization (Issue #599)
New package saltybot_nav2_uwb replacing AMCL-based localization with
UWB-IMU EKF fused pose. Key components:

- uwb_pose_bridge_node: subscribes /saltybot/pose/fused_cov (from EKF),
  computes map→odom TF via T_map_odom = T_map_base × inv(T_odom_base),
  broadcasts at 20 Hz. Publishes /initialpose on first valid pose.
- waypoint_sequencer.py: pure-Python state machine (IDLE→RUNNING→
  SUCCEEDED/ABORTED/CANCELED) for sequential waypoint execution.
- waypoint_follower_node: action server on /saltybot/nav/follow_waypoints
  (nav2_msgs/FollowWaypoints), sends each goal to Nav2 NavigateToPose
  in sequence; JSON topic /saltybot/nav/waypoints for operator use.
- nav2_uwb_params.yaml: DWB controller capped at 1.0 m/s, global+local
  costmap with /scan (RPLIDAR), rolling-window global costmap (no static
  map needed), AMCL removed from lifecycle manager.
- nav2_uwb.launch.py: bridge (t=0) → Nav2 (t=2s) → waypoint follower
  (t=4s) with LogInfo markers.
- 65 unit tests passing (waypoint dataclass, sequencer state machine,
  2-D TF maths, progress tracking).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:02:26 -04:00
c76d5b0dd7 feat: Multi-sensor pose fusion node (Issue #595)
New package saltybot_pose_fusion — EKF fusing UWB+IMU absolute pose,
visual odometry velocity, and raw IMU into a single authoritative pose.

pose_fusion_ekf.py (pure Python, no ROS2 deps):
  PoseFusionEKF — state [x, y, θ, vx, vy, ω], 6-state EKF.
  - predict_imu(ax_body, ay_body, omega, dt): body-frame IMU predict step
    with Jacobian F, bias-compensated accel, process noise Q.
  - update_uwb_position(x, y, sigma_m): absolute position measurement
    (H=[1,0,0,0,0,0; 0,1,0,0,0,0]) from UWB+IMU fused stream.
  - update_uwb_heading(heading_rad, sigma_rad): heading measurement.
  - update_vo_velocity(vx_body, omega, ...): VO velocity measurement —
    body-frame vx rotated to world via cos/sin(θ), updates [vx,vy,ω] state.
  - Joseph-form covariance update for numerical stability.
  - Dual dropout clocks: uwb_dropout_s, vo_dropout_s (reset on each update).
  - Velocity damping when uwb_dropout_s > 2s.
  - Sensor weight parameters: sigma_uwb_pos_m, sigma_uwb_head_rad,
    sigma_vo_vel_m_s, sigma_vo_omega_r_s, sigma_imu_accel/gyro,
    sigma_vel_drift, dropout_vel_damp.

pose_fusion_node.py (ROS2 node 'pose_fusion'):
  - Subscribes: /imu/data (Imu, 200Hz → predict), /saltybot/pose/fused_cov
    (PoseWithCovarianceStamped, 10Hz → position+heading update, σ extracted
    from message covariance when use_uwb_covariance=true), /saltybot/visual_odom
    (Odometry, 30Hz → velocity update, σ from twist covariance).
  - Publishes: /saltybot/pose/authoritative (PoseWithCovarianceStamped),
    /saltybot/pose/status (String JSON, 10Hz).
  - TF2: map→base_link broadcast at IMU rate.
  - Suppresses output when uwb_dropout_s > uwb_dropout_max_s (10s).
  - Warns (throttled) on UWB/VO dropout.

config/pose_fusion_params.yaml: sensor weights + dropout thresholds.
launch/pose_fusion.launch.py: single node launch with params_file arg.
test/test_pose_fusion_ekf.py: 13 unit tests — init, predict, UWB/VO
  updates, dropout reset, covariance shape/convergence, sigma override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:00:54 -04:00
sl-android
c62444cc0e chore: Register mqtt_ros2_bridge entry point and paho-mqtt dep (Issue #601)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:59:41 -04:00
sl-android
dd13569413 feat: MQTT-to-ROS2 phone sensor bridge (Issue #601)
Add saltybot_phone/mqtt_ros2_bridge_node.py — ROS2 node bridging the three
MQTT topics published by phone/sensor_dashboard.py into typed ROS2 messages:

  saltybot/phone/imu     → /saltybot/phone/imu     sensor_msgs/Imu
  saltybot/phone/gps     → /saltybot/phone/gps     sensor_msgs/NavSatFix
  saltybot/phone/battery → /saltybot/phone/battery sensor_msgs/BatteryState
  (status)               → /saltybot/phone/bridge/status std_msgs/String

Key design:
- paho-mqtt loop_start() runs in dedicated network thread; on_message
  enqueues (topic, payload) pairs into a thread-safe queue
- ROS2 timer drains queue at 50 Hz — all publishing stays on executor
  thread, avoiding any rclpy threading concerns
- Timestamp alignment: uses ROS2 wall clock by default; opt-in
  use_phone_timestamp param uses phone epoch ts when drift < warn_drift_s
- IMU: populates accel + gyro with diagonal covariance; orientation_cov[0]=-1
  (unknown per REP-145)
- GPS: NavSatStatus.STATUS_FIX for gps/fused/network providers; full 3×3
  position covariance from accuracy_m; COVARIANCE_TYPE_APPROXIMATED
- Battery: pct→percentage [0-1], temp Kelvin, health/status mapped from
  Android health strings, voltage/current=NaN (unavailable on Android)
- Input validation: finite value checks on IMU, lat/lon range on GPS,
  pct [0-100] on battery; bad messages logged at DEBUG and counted
- Status topic at 0.2 Hz: JSON {mqtt_connected, rx/pub/err counts,
  age_s per sensor, queue_depth}
- Auto-reconnect via paho reconnect_delay_set (5 s → 20 s max)

Add launch/mqtt_bridge.launch.py with args: mqtt_host, mqtt_port,
reconnect_delay_s, use_phone_timestamp, warn_drift_s, imu_accel_cov,
imu_gyro_cov.

Register mqtt_ros2_bridge console script in setup.py.
Add python3-paho-mqtt exec_depend to package.xml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:59:02 -04:00
816d165db4 feat: RPLIDAR A1 mount bracket (Issue #596) 2026-03-14 14:58:41 -04:00
cbcae34b79 feat: WebUI gamepad teleoperation panel (Issue #598)
- Standalone ui/gamepad_panel.{html,js,css} — no build step
- Web Gamepad API integration: L-stick=linear, R-stick=angular
  - LT trigger scales speed down (fine control)
  - B/Circle button toggles E-stop; Start button resumes
  - Live raw axis bars and button state in sidebar
- Virtual dual joystick (left=drive, right=steer) via Pointer Capture API
  - Deadzone ring drawn on canvas; configurable 0–40%
  - Touch and mouse support
- WASD/Arrow keyboard input (W/S=forward/reverse, A/D=turn, Space=E-stop)
- Speed limiter sliders: linear (0–1.0 m/s), angular (0–2.0 rad/s)
- Configurable deadzone slider (0–40%)
- E-stop: latches zero-velocity command, blinking overlay, resume button
- Publishes geometry_msgs/Twist to /cmd_vel at 20 Hz via rosbridge WebSocket
- Input priority: gamepad > keyboard > virtual sticks
- Live command display (m/s, rad/s) with color feedback
- Pub rate display (Hz) in sidebar
- localStorage WS URL persistence, auto-reconnect on load
- Mobile-responsive: sidebar hidden ≤800px, right stick hidden ≤560px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:58:41 -04:00
061189670a Merge pull request 'feat: STM32 watchdog and fault recovery handler (Issue #565)' (#583) from sl-firmware/issue-565-fault-handler into main 2026-03-14 13:54:22 -04:00
8fbe7c0033 feat: STM32 watchdog and fault recovery handler (Issue #565)
- New src/fault_handler.c + include/fault_handler.h:
  - HardFault/MemManage/BusFault/UsageFault naked ISR stubs with
    Cortex-M7 stack-frame capture (R0-R3, LR, PC, xPSR, CFSR, HFSR,
    MMFAR, BFAR, SP) and NVIC_SystemReset()
  - .noinit SRAM capture ring survives soft reset; persisted to flash
    sector 7 (0x08060000, 8x64-byte slots) on subsequent boot
  - MPU Region 0 stack guard (32 B at __stack_end, no-access) ->
    MemManage fault detected as FAULT_STACK_OVF
  - Brownout detect via RCC_CSR_BORRSTF on boot -> FAULT_BROWNOUT
  - Watchdog reset detection delegates to existing watchdog.c
  - LED blink codes on LED2 (PC14, active-low) for 10 s post-recovery:
    HARDFAULT=3, WATCHDOG=2, BROWNOUT=1, STACK_OVF=4 fast blinks
  - fault_led_tick(), fault_log_read(), fault_log_get_count(),
    fault_get_last_type(), fault_log_clear(), FAULT_ASSERT() macro
- jlink.h: add JLINK_CMD_FAULT_LOG_GET (0x0F), JLINK_TLM_FAULT_LOG
  (0x86), jlink_tlm_fault_log_t (20 bytes), fault_log_req in JLinkState,
  jlink_send_fault_log() declaration
- jlink.c: dispatch JLINK_CMD_FAULT_LOG_GET; implement
  jlink_send_fault_log() (26-byte CRC16-XModem framed response)
- main.c: call fault_handler_init() first in main(); send fault log
  TLM on boot if prior fault recorded; fault_led_tick() in main loop;
  handle fault_log_req flag to respond to Jetson queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 13:37:14 -04:00
15ff5acca7 Merge pull request 'feat: Visual odometry from RealSense stereo ORB (Issue #586)' (#593) from sl-perception/issue-586-visual-odom into main 2026-03-14 13:32:56 -04:00
f2743198e5 Merge pull request 'feat: WebUI map view (Issue #587)' (#591) from sl-webui/issue-587-map-view into main 2026-03-14 13:32:50 -04:00
6512c805be Merge pull request 'feat: Motor current monitoring (Issue #584)' (#594) from sl-controls/issue-584-motor-current into main 2026-03-14 13:32:30 -04:00
1da1d50171 Merge pull request 'feat: Phone video bridge (Issue #585)' (#592) from sl-android/issue-585-video-bridge into main 2026-03-14 13:32:24 -04:00
6a8b6a679e Merge pull request 'feat: Integrate UWB tag display + ESP-NOW + e-stop (salty/uwb-tag-display-wireless)' (#590) from sl-uwb/issue-merge-uwb-tag-display into main 2026-03-14 13:32:19 -04:00
ddf8332cd7 Merge pull request 'feat: Battery holder bracket (Issue #588)' (#589) from sl-mechanical/issue-588-battery-holder into main 2026-03-14 13:32:16 -04:00
e9429e6177 Merge pull request 'feat: ROS2 launch orchestrator for full SaltyBot bringup (Issue #577)' (#582) from sl-jetson/issue-577-bringup-launch into main 2026-03-14 13:32:10 -04:00
2b06161cb4 feat: Motor current monitoring and overload protection (Issue #584)
Adds ADC-based motor current sensing with configurable overload threshold,
soft PWM limiting, hard cutoff on sustained overload, and auto-recovery.

Changes:
- include/motor_current.h: MotorCurrentState enum (NORMAL/SOFT_LIMIT/COOLDOWN),
  thresholds (5A hard, 4A soft, 2s overload, 10s cooldown), full API
- src/motor_current.c: reads battery_adc_get_current_ma() each tick (reuses
  existing ADC3 IN13/PC3 DMA sampling); linear PWM scale in soft-limit zone
  (scale256 fixed-point); fault counter + one-tick fault_pending flag for
  main-loop fault log integration; telemetry at MOTOR_CURR_TLM_HZ (5 Hz)
- include/pid_flash.h: add pid_sched_entry_t (16 bytes), pid_sched_flash_t
  (128 bytes at 0x0807FF40), PID_SCHED_MAX_BANDS=6, pid_flash_load_schedule(),
  pid_flash_save_all() — fixes missing types needed by jlink.h (Issue #550)
- src/pid_flash.c: implement flash_write_words() helper, pid_flash_load_schedule(),
  pid_flash_save_all() — single sector-7 erase covers both schedule and PID records
- include/jlink.h: add JLINK_TLM_MOTOR_CURRENT (0x86), jlink_tlm_motor_current_t
  (8 bytes: current_ma, limit_pct, state, fault_count), jlink_send_motor_current_tlm()
- src/jlink.c: implement jlink_send_motor_current_tlm() (14-byte frame)

Motor overload state machine:
  MC_NORMAL     : current_ma < 4000 mA — full PWM authority
  MC_SOFT_LIMIT : 4000-5000 mA — linear reduction (0% at 4A → 100% at 5A)
  MC_COOLDOWN   : >5A sustained 2s → zero output for 10s then NORMAL

Main-loop integration:
  motor_current_tick(now_ms);
  if (motor_current_fault_pending()) fault_log_append(FAULT_MOTOR_OVERCURRENT);
  cmd = motor_current_apply_limit(balance_pid_output());
  motor_current_send_tlm(now_ms);

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:25:29 -04:00
c1b82608d5 feat: Visual odometry from RealSense stereo ORB (Issue #586)
Adds stereo ORB-based visual odometry to saltybot_visual_odom package.

New modules:
- orb_stereo_matcher.py: ORB feature detection (cv2.ORB_create) with BFMatcher
  NORM_HAMMING + Lowe ratio test for temporal matching (infra1 prev→curr).
  Stereo scale method matches infra1↔infra2 under epipolar row constraint
  (|Δrow|≤2px), computes depth = baseline_m * fx / disparity.
- stereo_orb_node.py: StereoOrbNode subscribes to infra1+infra2+depth
  (ApproximateTimeSynchronizer 3-topic), detects/matches ORB temporally,
  estimates SE(3) via Essential matrix (5-point RANSAC) using StereoVO,
  recovers metric scale from D435i aligned depth (primary) or stereo
  baseline disparity (fallback). Publishes nav_msgs/Odometry on
  /saltybot/visual_odom and broadcasts TF2 odom→camera_link. Baseline
  auto-updated from infra2 camera_info Tx (overrides parameter).
- config/stereo_orb_params.yaml, launch/stereo_orb.launch.py
- setup.py: adds stereo_orb entrypoint, installs launch+config dirs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:21:58 -04:00
sl-android
08bc23f6df feat: Phone video streaming bridge (Issue #585)
Phone side — phone/video_bridge.py:
- MJPEG streaming server for Android/Termux phone camera
- Dual camera backends: OpenCV VideoCapture (V4L2) with automatic
  fallback to termux-camera-photo for unmodified Android
- WebSocket server (ws://0.0.0.0:8765) — binary JPEG frames + JSON
  info/error control messages; supports multiple concurrent clients
- HTTP server (http://0.0.0.0:8766):
    /stream    — multipart/x-mixed-replace MJPEG
    /snapshot  — single JPEG
    /health    — JSON stats (frame count, dropped, resolution, fps)
- Thread-safe single-slot FrameBuffer; CaptureThread rate-limited with
  wall-clock accounting for capture latency
- Flags: --ws-port, --http-port, --width, --height, --fps, --quality,
  --device, --camera-id, --no-http, --debug

Jetson side — saltybot_phone/phone_camera_node.py:
- ROS2 node: receives JPEG frames, publishes:
    /saltybot/phone/camera            sensor_msgs/Image (bgr8)
    /saltybot/phone/camera/compressed sensor_msgs/CompressedImage
    /saltybot/phone/camera/info       std_msgs/String (stream metadata)
- WebSocket client (primary); HTTP MJPEG polling fallback on WS failure
- Auto-reconnect loop (default 3 s) for both transports
- Latency warning when frame age > latency_warn_ms (default 200 ms)
- 10 s diagnostics log: received/published counts + last frame age
- Registered as phone_camera_node console script in setup.py
- Added to phone_bringup.py launch with phone_host / phone_cam_enabled args

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:20:28 -04:00
5dac6337e6 feat: WebUI map view (Issue #587)
Standalone 3-file 2D map panel (ui/map_panel.{html,js,css}).
No build step. Open directly or serve ui/ directory.

Canvas layers (drawn every animation frame):
  - Grid lines (1m spacing) + world-origin axis cross + 1m scale bar
  - RPLIDAR scan dots (/scan, green, cbor-compressed, 100ms throttle)
  - Safety zone rings: danger 0.30m (red dashed) + warn 1.00m (amber dashed)
  - 100-position breadcrumb trail with fading cyan polyline + dots every 5 pts
  - UWB anchor markers (amber diamond + label, user-configured)
  - Robot marker: circle + forward arrow, red when e-stopped

Interactions:
  - Mouse wheel zoom (zooms around cursor)
  - Click+drag pan
  - Pinch-to-zoom (touch, two-finger)
  - Auto-center toggle (robot stays centered when on)
  - Zoom +/- buttons, Reset view button
  - Clear trail button
  - Mouse hover shows world coords (m) in bottom-left HUD

ROS topics:
  SUB /saltybot/pose/fused        geometry_msgs/PoseStamped   50ms throttle
  SUB /scan                       sensor_msgs/LaserScan       100ms + cbor
  SUB /saltybot/safety_zone/status std_msgs/String (JSON)     200ms throttle

Sidebar:
  - Robot position (x, y m) + heading (°)
  - Safety zone: forward zone (CLEAR/WARN/DANGER), closest obstacle (m), e-stop
  - UWB anchor manager: add/remove anchors with x/y/label, persisted localStorage
  - Topic reference

E-stop banner: pulsing red overlay when /saltybot/safety_zone/status estop_active=true

Mobile-responsive: sidebar hidden on <700px, canvas fills viewport.
WS URL persisted in localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:20:04 -04:00
sl-uwb
4b8d1b2ff7 feat: Integrate UWB tag display + ESP-NOW + e-stop (salty/uwb-tag-display-wireless)
Integrates Tee's additions to the DS-TWR tag firmware (esp32/uwb_tag/).
Base is our DS-TWR initiator from Issue #545; extensions added:

OLED display (SSD1306 128×64, I2C SDA=4 SCL=5):
- Big distance readout (nearest anchor, auto m/mm)
- Per-anchor range rows with link-age indicator
- Signal strength bars (RSSI)
- Uptime + sequence counter
- Full-screen E-STOP warning when button held

ESP-NOW wireless (peer-to-peer, no AP required):
- 20-byte broadcast packet: magic, tag_id, msg_type, anchor_id,
  range_mm, rssi_dbm, timestamp_ms, battery_pct, flags, seq_num
- MSG_RANGE (0x10) on every successful TWR
- MSG_ESTOP (0x20) at 10 Hz while button held; 3× clear on release
- MSG_HEARTBEAT (0x30) at 1 Hz

Emergency stop (GPIO 0 / BOOT button, active LOW):
- Blocks ranging while active
- 10 Hz ESP-NOW e-stop TX, serial +ESTOP:ACTIVE / +ESTOP:CLEAR
- 3× clear packets on release

Build: adds Adafruit SSD1306 + GFX libraries to platformio.ini.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:19:31 -04:00
5556c06153 feat: Battery holder bracket (Issue #588) 2026-03-14 12:18:37 -04:00
5a1290a8f9 Merge pull request 'feat: UWB anchor mount bracket (Issue #564)' (#569) from sl-mechanical/issue-564-uwb-anchor-mount into main 2026-03-14 12:15:43 -04:00
7b75cdad1a feat: UWB anchor mount bracket (Issue #564) 2026-03-14 12:15:12 -04:00
b09017c949 Merge pull request 'feat: UWB-IMU EKF fusion for robust indoor localization (Issue #573)' (#581) from sl-uwb/issue-573-uwb-imu-fusion into main 2026-03-14 12:14:05 -04:00
1726558a7a Merge pull request 'feat: RPLIDAR safety zone detector (Issue #575)' (#580) from sl-perception/issue-575-safety-zone into main 2026-03-14 12:14:01 -04:00
5a3f4d1df6 Merge pull request 'feat: WebUI event log panel (Issue #576)' (#579) from sl-webui/issue-576-event-log into main 2026-03-14 12:13:56 -04:00
b2f01b42f3 Merge pull request 'feat: Termux sensor dashboard (Issue #574)' (#578) from sl-android/issue-574-sensor-dashboard into main 2026-03-14 12:13:51 -04:00
a7eb2ba3e5 Merge pull request 'feat: PID gain scheduling for speed-dependent balance (Issue #550)' (#560) from sl-controls/issue-550-pid-scheduling into main 2026-03-14 12:13:44 -04:00
4035b4cfc3 feat: ROS2 launch orchestrator for full SaltyBot bringup (Issue #577)
Adds saltybot_bringup.launch.py with ordered startup groups (drivers→
perception→navigation→UI), timer-based health gates, configurable
profiles (minimal/full/debug), and estop on Ctrl-C shutdown.

Also adds launch_profiles.py dataclass module and 53-test coverage for
profile hierarchy, timing gates, safety bounds, and to_dict serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:57:57 -04:00
sl-uwb
7708c63698 feat: UWB-IMU EKF fusion for robust indoor localization (Issue #573)
EKF fusing UWB position (10Hz) with IMU accel+gyro (200Hz) for
SaltyBot indoor localization with UWB dropout resilience.

Package: saltybot_uwb_imu_fusion

- ekf_math.py: 6-state EKF [x,y,θ,vx,vy,ω], IMU predict + UWB update
  - IMU as process input: body-frame accel rotated to world via heading
  - Jacobian F for nonlinear rotation effect
  - Process noise Q from continuous white-noise model
  - UWB 2D position update, heading update from quaternion
  - Accel bias estimation (low-pass)
  - Velocity damping during UWB dropout (>2s threshold)
- ekf_node.py: ROS2 node subscribing to /imu/data (200Hz) + /saltybot/uwb/pose
  or /uwb/bearing (10Hz)
  - Publishes /saltybot/pose/fused (PoseStamped)
  - Publishes /saltybot/pose/fused_cov (PoseWithCovarianceStamped)
  - Broadcasts base_link → map TF2 at IMU rate
  - Suppresses output after max_dead_reckoning_s without UWB
- 14/14 unit tests passing (predict, update, dropout, PD covariance)
- Launch: ros2 launch saltybot_uwb_imu_fusion uwb_imu_fusion.launch.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:55:43 -04:00
131d85a0d3 feat: RPLIDAR safety zone detector (Issue #575)
Add saltybot_safety_zone — ROS2 Python node that processes the RPLIDAR
A1M8 /scan into three concentric 360° safety zones, latches an e-stop
when DANGER is detected in the forward arc, and overrides /cmd_vel to
zero while the latch is active.

Zone thresholds (default):
  DANGER  < 0.30 m — latching e-stop in forward arc
  WARN    < 1.00 m — advisory (published in sector data)
  CLEAR   otherwise

Sector grid:
  36 sectors of 10° each (sector 0 = robot forward, CCW positive).
  Per-sector: angle_deg, zone, min_range_m, in_forward_arc flag.

E-stop behaviour:
  - Latches after estop_debounce_frames (2) consecutive DANGER scans
    in the forward arc (configurable ±30°, or all-arcs mode).
  - While latched: zero Twist published to /cmd_vel every scan + every
    incoming /cmd_vel_input message is blocked.
  - Clear only via service (obstacle must be gone):
    /saltybot/safety_zone/clear_estop  (std_srvs/Trigger)

Published topics:
  /saltybot/safety_zone          String/JSON  every scan
    — per-sector {sector, angle_deg, zone, min_range_m, forward}
    — estop_active, estop_reason, danger_sectors[], warn_sectors[]
  /saltybot/safety_zone/status   String/JSON  10 Hz
    — forward_zone, closest_obstacle_m, danger/warn counts
  /cmd_vel                       Twist        zero when e-stopped

Subscribed topics:
  /scan           LaserScan  — RPLIDAR A1M8
  /cmd_vel_input  Twist      — upstream velocity (pass-through / block)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:54:52 -04:00
44691742c8 feat: WebUI event log panel (Issue #576)
Standalone 3-file filterable real-time event log (no build step).

Files:
  ui/event_log_panel.html  — layout, toolbar, empty state
  ui/event_log_panel.js    — rosbridge subscriptions, ring buffer, render
  ui/event_log_panel.css   — dark-theme, responsive grid layout

Features:
- 1000-entry ring buffer (oldest dropped when full, FIFO)
- Subscribes /rosout (rcl_interfaces/msg/Log) + /saltybot/events (std_msgs/String JSON)
- Severity filter buttons: DEBUG / INFO / WARN / ERROR / FATAL / EVENT (toggle on/off)
- Node name filter: select dropdown populated from seen nodes
- Live text search with <mark> highlight, Ctrl+F shortcut, Esc to clear
- Auto-scroll to latest entry; pauses on mouse hover (messages still buffered)
- Manual pause/resume button; detects user scroll-up and stops auto-scroll
- CSV export of current filtered view with timestamp (filename includes ISO date)
- Clear all entries button
- Color-coded by severity: left border stripe + text color per level
- Entry columns: timestamp (ms precision) | severity | node | message
- [system] entries for connect/disconnect events
- WS URL persisted in localStorage
- Responsive: node column hidden on narrow screens

ROS topics:
  SUB /rosout               rcl_interfaces/msg/Log  (all nodes)
  SUB /saltybot/events      std_msgs/String (JSON: {level,node,msg})

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:54:13 -04:00
sl-android
814624045a feat: Termux sensor dashboard (Issue #574)
Add phone/sensor_dashboard.py — publishes phone sensors to SaltyBot MQTT:

- IMU  → saltybot/phone/imu     @ 5 Hz  (accelerometer + gyroscope via
  termux-sensor -s <name> -n 1)
- GPS  → saltybot/phone/gps     @ 1 Hz  (lat/lon/alt/accuracy/speed/bearing
  via termux-location; GPS→network fallback on cold start)
- Battery → saltybot/phone/battery @ 1 Hz (pct/charging/temp/health/plugged
  via termux-battery-status)
- paho-mqtt with loop_start() + on_connect/on_disconnect callbacks for
  automatic reconnect (exponential back-off, max 60 s)
- Each sensor runs in its own daemon thread (SensorPoller); rate enforced
  by wall-clock sleep accounting for read latency
- 30 s status log: per-poller publish/error counts + MQTT state
- Flags: --broker, --port, --imu-hz, --gps-hz, --bat-hz, --qos,
  --no-imu, --no-gps, --no-battery, --debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:53:04 -04:00
929c9ecd74 feat: UWB anchor mount bracket (Issue #564) 2026-03-14 11:51:50 -04:00
8592361095 feat: PID gain scheduling for speed-dependent balance (Issue #550)
Implements a speed-dependent PID gain scheduler that interpolates Kp/Ki/Kd
across a configurable table of velocity breakpoints, replacing the fixed
single-gain PID used previously.

Changes:
- include/pid_flash.h: add pid_sched_entry_t (16-byte entry), pid_sched_flash_t
  (128-byte record at 0x0807FF40), pid_flash_load_schedule(), pid_flash_save_all()
  (atomic single-sector erase for both schedule and single-PID records)
- src/pid_flash.c: implement load_schedule and save_all; single erase covers
  both records at 0x0807FF40 (schedule) and 0x0807FFC0 (single PID)
- include/pid_schedule.h: API header -- init, get_gains, apply, set/get table,
  flash_save, active_band_idx, get_default_table
- src/pid_schedule.c: linear interpolation between sorted speed-band entries;
  integrator reset on band transition; default 3-band table (0/0.3/0.8 m/s)
- include/jlink.h: add SCHED_GET (0x0C), SCHED_SET (0x0D), SCHED_SAVE (0x0E)
  commands; TLM_SCHED (0x85); jlink_tlm_sched_t; JLinkSchedSetBuf;
  sched_get_req, sched_save_req fields in JLinkState; include pid_flash.h
- src/jlink.c: dispatch SCHED_GET/SET/SAVE; implement jlink_send_sched_telemetry,
  jlink_get_sched_set; add JLinkSchedSetBuf static buffer
- test/test_pid_schedule.c: 48 unit tests -- all passing (gcc host build)

Flash layout (sector 7):
  0x0807FF40  pid_sched_flash_t (128 bytes) -- schedule
  0x0807FFC0  pid_flash_t       ( 64 bytes) -- single PID (existing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:51:11 -04:00
35440b7463 Merge pull request 'feat: ROS2 sensor health monitor (Issue #566)' (#572) from sl-jetson/issue-566-health-monitor into main 2026-03-14 11:49:55 -04:00
d36b79371d Merge pull request 'feat: ESP32 UWB Pro anchor firmware — DS-TWR responder (Issue #544)' (#570) from sl-uwb/issue-544-anchor-firmware into main 2026-03-14 11:49:51 -04:00
3b0b9d0f16 Merge pull request 'feat: UWB tag firmware (Issue #545)' (#568) from sl-perception/issue-546-uwb-ros2 into main 2026-03-14 11:49:43 -04:00
4116232b27 Merge pull request 'feat: WebUI diagnostics dashboard (Issue #562)' (#567) from sl-webui/issue-562-diagnostics into main 2026-03-14 11:49:39 -04:00
c7dcce18c2 feat: UWB anchor mount bracket wall/ceiling design (Issue #564) 2026-03-14 11:47:07 -04:00
8e03a209be feat: ROS2 sensor health monitor (Issue #566)
Add sensor_health_node to saltybot_health_monitor package. Monitors 8
sensor topics for staleness, publishing DiagnosticArray on
/saltybot/diagnostics and MQTT JSON on saltybot/health.

Sensors monitored (configurable thresholds):
  /camera/color/image_raw, /camera/depth/image_rect_raw,
  /camera/color/camera_info, /scan, /imu/data,
  /saltybot/uwb/range, /saltybot/battery, /saltybot/motor_daemon/status

Each sensor: OK/WARN/ERROR based on topic age vs warn_s/error_s thresholds.
Critical sensors (camera, lidar, imu, motor_daemon) escalate overall status.

Files added:
  sensor_health_node.py — SensorWatcher + SensorHealthNode
  config/sensor_health_params.yaml — per-sensor thresholds
  launch/sensor_health.launch.py
  test/test_sensor_health.py — 35 tests, all passing

setup.py/package.xml updated: sensor_msgs, diagnostic_msgs deps + new entry point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:47:01 -04:00
sl-uwb
a4879b6b3f feat: ESP32 UWB Pro anchor firmware — DS-TWR responder (Issue #544)
Anchor firmware for Makerfabs ESP32 UWB Pro (DW3000 chip). Two anchors
mount on SaltyBot (port/starboard), USB-connected to Jetson Orin.

- DS-TWR responder: Poll→Resp→Final with ±10cm accuracy
- Streams +RANGE:<id>,<mm>,<rssi_dbm> on Serial 115200
- AT command interface: AT+RANGE?, AT+RANGE_ADDR=, AT+ID?
- ANCHOR_ID 0/1 set at build time (env:anchor0 / env:anchor1)
- PlatformIO config for Makerfabs MaUWB_DW3000 library
- udev rules for /dev/uwb-anchor0 /dev/uwb-anchor1 USB symlinks
- Pin map: SCK=18 MISO=19 MOSI=23 CS=21 RST=27 IRQ=34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:45:29 -04:00
2180b61440 feat: ROS2 UWB position node (Issue #546)
Add saltybot_uwb_position — ROS2 Python package that reads JSON range
measurements from an ESP32 DW3000 UWB tag over USB serial, trilaterates
the robot's absolute position from 3+ fixed infrastructure anchors, and
publishes position + TF2 to the rest of the stack.

Serial protocol (one JSON line per frame):
  Full frame: {"ts":…, "ranges": [{"id":0,"d_mm":1500,"rssi":-65}, …]}
  Per-anchor: {"id":0, "d_mm":1500, "rssi":-65.0}
  Accepts both "d_mm" and "range_mm" field names.

Trilateration (trilateration.py, numpy, no ROS deps):
  Linear least-squares: linearise sphere equations around anchor 0,
  solve (N-1)x2 (2D) or (N-1)x3 (3D) system via np.linalg.lstsq.
  2D mode (default): robot_z fixed, needs >=3 anchors.
  3D mode (solve_z=true): full 3D, needs >=4 anchors.

Outlier rejection:
  After initial solve, compute per-anchor residual |r_meas - r_pred|.
  Reject anchors with residual > outlier_threshold_m (0.4 m default).
  Re-solve with inliers if >= min_anchors remain.
  Track consecutive outlier strikes; flag in /status after N strikes.

Kalman filter (KalmanFilter3D, constant-velocity, 6-state, numpy):
  Predict-only coasting when anchors drop below minimum.
  Q=0.05, R=0.10 (tunable).

Topics:
  /saltybot/uwb/pose       PoseStamped  10 Hz Kalman-filtered position
  /saltybot/uwb/range/<id> UwbRange     on arrival, raw per-anchor ranges
  /saltybot/uwb/status     String/JSON  10 Hz state+residuals+flags

TF2: uwb_link -> map (identity rotation)

Anchor config: flat float arrays in YAML.
Default layout: 4-anchor 5x5m room at 2m height.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:43:22 -04:00
c2d9adad25 feat: WebUI diagnostics dashboard (Issue #562)
Standalone 3-file diagnostics dashboard (ui/diagnostics_panel.{html,js,css}).
No build step — serve the ui/ directory directly. roslib.js via CDN.

Panels:
- Battery: voltage (V), SOC (%), current (A) with large readouts + gauge bars
  + 2-minute sparkline history canvas, 4S LiPo thresholds
- Temperatures: CPU/GPU (Jetson tegrastats) + Board/STM32 + Motor L/R
  color-coded temp boxes with mini progress bars (green<60 amber<75 red>75°C)
- Motor current: per-wheel current gauge bars + CMD value + balance_state label
  Thresholds: warn 8A / crit 12A
- Resources: RAM / GPU memory / Disk — gauge bars with used/total display
  Thresholds: warn 80% / crit 95%
- WiFi / Network: RSSI signal bars (5-level) + dBm readout + latency (ms)
  MQTT broker status via mqtt_connected KeyValue
- ROS2 node health: full DiagnosticArray node list with OK/WARN/ERROR/STALE
  badges, per-node message preview, MutationObserver count badge

Features:
- Auto 2 Hz refresh via rosbridge subscriptions (throttle_rate: 500ms)
- Pulsing refresh indicator dot on each update
- System status bar: HEALTHY/DEGRADED/FAULT/STALE badge + battery/thermal/net
- Alert thresholds: red/amber/green for every metric
- Responsive CSS grid: 3-col → 2-col → 1-col via media queries
- WS URL persisted in localStorage

ROS topics:
  SUB /diagnostics              diagnostic_msgs/DiagnosticArray
  SUB /saltybot/balance_state   std_msgs/String (JSON)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:41:43 -04:00
76668d8346 Merge pull request 'feat: RPLIDAR A1 mount bracket (Issue #561)' (#563) from sl-mechanical/issue-561-rplidar-mount into main 2026-03-14 11:41:10 -04:00
d8e5490a0e feat: RPLIDAR A1 mount bracket (Issue #561) 2026-03-14 11:40:17 -04:00
6409360428 Merge pull request 'feat: Pan/tilt gimbal servo driver for ST3215 bus servos (Issue #547)' (#559) from sl-firmware/issue-547-gimbal-servo into main 2026-03-14 11:40:02 -04:00
6c5ecc9e00 Merge pull request 'feat: ROS2 gimbal control node (Issue #548)' (#558) from sl-jetson/issue-548-gimbal-ros2 into main 2026-03-14 11:39:58 -04:00
df6b79d676 Merge pull request 'feat: WebUI gimbal control panel (Issue #551)' (#557) from sl-webui/issue-551-gimbal-webui into main 2026-03-14 11:36:49 -04:00
0dbd64a6f4 Merge pull request 'feat: Camera gimbal mount for RealSense D435i (Issue #552)' (#556) from sl-mechanical/issue-552-gimbal-mount into main 2026-03-14 11:36:31 -04:00
8e21201dd4 Merge pull request 'feat: Person-following head tracking (Issue #549)' (#555) from sl-perception/issue-549-head-tracking into main 2026-03-14 11:36:29 -04:00
80e3b23aec Merge pull request 'feat: Phone voice command interface (Issue #553)' (#554) from sl-android/issue-553-voice-command into main 2026-03-14 11:36:27 -04:00
36643dd652 feat: Pan/tilt gimbal servo driver for ST3215 bus servos (Issue #547)
- servo_bus.c/h: half-duplex USART3 driver for Feetech ST3215 servos at
  1 Mbps; blocking TX/RX with CRC checksum; read/write position, torque
  enable, speed; deg<->raw conversion (center=2048, 4096 counts/360°)
- gimbal.c/h: gimbal_t controller; 50 Hz feedback poll alternating pan/tilt
  at 25 Hz each; clamps to ±GIMBAL_PAN/TILT_LIMIT_DEG soft limits
- jlink.c: dispatch JLINK_CMD_GIMBAL_POS (0x0B, 6-byte payload int16+int16+
  uint16); jlink_send_gimbal_state() for JLINK_TLM_GIMBAL_STATE (0x84)
- main.c: servo_bus_init() + gimbal_init() on boot; gimbal_tick() in main
  loop; gimbal_updated flag handler; GIMBAL_STATE telemetry at 50 Hz
- config.h: SERVO_BUS_UART/PORT/PIN/BAUD, GIMBAL_PAN/TILT_ID, GIMBAL_TLM_HZ,
  GIMBAL_PAN/TILT_LIMIT_DEG
- jlink.h: CMD_GIMBAL_POS, TLM_GIMBAL_STATE, jlink_tlm_gimbal_state_t (10 B),
  gimbal_updated/pan_x10/tilt_x10/speed volatile fields in JLinkState

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:38:06 -04:00
da6a17cdcb feat: ROS2 gimbal control node (Issue #548)
saltybot_gimbal ROS2 Python package for pan/tilt camera head control
via JLINK binary protocol over serial to STM32 (Issue #547 C side).

- gimbal_node.py: subscribes /saltybot/gimbal/cmd (Vector3: pan, tilt,
  speed), publishes /saltybot/gimbal/state (JSON), /saltybot/gimbal/cmd_echo
- Services: /saltybot/gimbal/home (Trigger), /saltybot/gimbal/look_at
  (Trigger + /saltybot/gimbal/look_at_target PointStamped)
- jlink_gimbal.py: JLINK codec matching jlink.h — CMD_GIMBAL_POS=0x0B,
  TLM_GIMBAL_STATE=0x84, CRC16-CCITT, deg*10 encoding, speed register
- MotionAxis: trapezoidal velocity profile (configurable accel + speed)
- Configurable limits: pan ±150°, tilt ±45° (gimbal_params.yaml)
- Serial reconnect with configurable retry delay
- 48 unit tests — all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:34:06 -04:00
cc3a65f4a4 feat: WebUI gimbal control panel (Issue #551)
Adds a full gimbal control panel with live camera preview:

Standalone page (ui/gimbal_panel.html + .js + .css):
- Self-contained HTML page, no build step, served directly
- roslib.js via CDN, connects to rosbridge WebSocket
- 2-D canvas pan/tilt pad: click-drag + touch pointer capture
- Live camera stream (front/rear/left/right selector, base64 CompressedImage)
- FPS badge + angle overlay on video feed
- Preset positions: CENTER / LEFT / RIGHT / UP / DOWN
- Home button (0° / 0°)
- Person-tracking toggle → /gimbal/tracking_enabled
- Current angle display from /gimbal/state feedback
- WS URL persisted in localStorage

React component (GimbalPanel.jsx) + App.jsx integration:
- Same features in dashboard — TELEOP group → Gimbal tab
- Shares rosbridge connection from parent
- Mobile-responsive: stacks vertically on mobile, side-by-side on lg+

ROS topics:
  PUB /gimbal/cmd              geometry_msgs/Vector3
  SUB /gimbal/state            geometry_msgs/Vector3
  PUB /gimbal/tracking_enabled std_msgs/Bool
  SUB /camera/*/image_raw/compressed sensor_msgs/CompressedImage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:29:29 -04:00
c68b751590 feat: Person-following head tracking (Issue #549)
Add saltybot_head_tracking — ROS2 Python node for automatic person-
following using dual-axis PID control targeting the pan/tilt camera head.

Pipeline:
  1. Subscribe to /saltybot/objects (DetectedObjectArray from YOLOv8n)
  2. Filter for class_id==0 (person); select best target by score:
       score = 0.6 * 1/(1+dist_m)  +  0.4 * confidence
     (falls back to confidence-only when distance_m==0 / unknown)
  3. Compute pixel error of bbox centre from image centre
  4. Apply dead-zone (10 px default) to suppress micro-jitter
  5. Convert pixel error to angle error via camera FOV
  6. Independent PID controllers for pan and tilt axes
  7. Accumulate PID output into absolute angle setpoint
  8. Publish geometry_msgs/Point to /saltybot/gimbal/cmd:
       x = pan_angle_deg, y = tilt_angle_deg, z = confidence

State machine:
  IDLE      -> waiting for first detection
  TRACKING  -> active PID
  LOST      -> hold last angle for hold_duration_s (3 s)
  CENTERING -> return to (0, 0) at 20 deg/s -> IDLE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:28:17 -04:00
0fd6ea92b0 feat: Camera gimbal mount bracket for RealSense D435i (Issue #552) 2026-03-14 10:28:03 -04:00
sl-android
c249b2d74e feat: Phone voice command interface (Issue #553)
Add phone/voice_commander.py — Termux-based voice command listener for SaltyBot:
- Continuous wake word detection ('Hey Salty') via Whisper STT on short audio clips
- Command recording after wake word, transcribed with local Whisper (tiny/base/small)
- Parses go forward/back/left/right, stop, follow me, go home, look at me
- Publishes JSON to /saltybot/voice/cmd via ROS2 (rclpy) or rosbridge WebSocket
- TTS confirmation via termux-tts-speak; 'Yes?' prompt on wake word
- Fuzzy token-overlap fallback for wake word matching
- Flags: --host, --port, --model, --threshold, --record-sec, --no-tts, --debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:26:38 -04:00
59d164944d Merge origin/sl-controls/issue-533-battery-adc — resolve jlink conflicts
Keep both Issue #531 (PID_RESULT telemetry) and Issue #533 (BATTERY
telemetry) additions in include/jlink.h and src/jlink.c.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:10:26 -05:00
6e09d13dfc Merge pull request 'feat: Audio pipeline — wake word + STT + TTS on Jabra SPEAK 810 (Issue #503)' (#543) from sl-jetson/issue-503-audio-pipeline into main 2026-03-07 10:07:16 -05:00
salty
02217443ea chore: merge CAD files and design docs from seb/saltylab seed repo
Consolidating seb/saltylab into saltylab-firmware before deleting the seed repo.
- 16 OpenSCAD CAD models → cad/
- Design docs (SALTYLAB.md, PLATFORM.md, AGENTS.md, board-viz.html) → docs/
2026-03-07 10:04:24 -05:00
14164089dc feat: Audio pipeline end-to-end (Issue #503)
- Add VoskSTT class to audio_utils.py: offline Vosk STT backend as
  low-latency CPU alternative to Whisper for Jetson deployments
- Update audio_pipeline_node.py: stt_backend param ("whisper"/"vosk"),
  Vosk loading with Whisper fallback, CPU auto-detection for Whisper,
  dual-backend _process_utterance dispatch, STT/<backend> log prefix
- Update audio_pipeline_params.yaml: add stt_backend and vosk_model_path
- Add test/test_audio_pipeline.py: 40 unit tests covering EnergyVAD,
  PCM conversion, AudioBuffer, UtteranceSegmenter, VoskSTT, JabraAudioDevice,
  AudioMetrics, AudioState
- Integrate into full_stack.launch.py: audio_pipeline at t=5s with
  enable_audio_pipeline and audio_stt_backend args

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:03:31 -05:00
6d316514da Merge remote-tracking branch 'origin/sl-firmware/issue-531-pid-autotune' 2026-03-07 10:03:24 -05:00
2d97033539 Merge remote-tracking branch 'origin/sl-perception/issue-532-depth-costmap' 2026-03-07 10:03:24 -05:00
71ec357c93 Merge remote-tracking branch 'origin/sl-webui/issue-534-teleop-webui' 2026-03-07 10:03:24 -05:00
cc0ffd1999 feat: Battery voltage ADC driver with DMA sampling (Issue #533)
STM32F7 ADC driver for battery voltage/current monitoring using
DMA-based continuous sampling, IIR low-pass filter, voltage divider
calibration, and USART telemetry to Jetson. Integrates with power
management for low-battery sleep (Issue #467).

Implementation:
- include/battery_adc.h: New driver header with calibration struct and
  public API (init, tick, get_voltage_mv, get_current_ma, calibrate,
  publish, check_pm, is_low, is_critical)
- src/battery_adc.c: ADC3 continuous-scan DMA (DMA2_Stream0/Ch2), 4x
  hardware oversampling of both Vbat (PC1/IN11) and Ibat (PC3/IN13),
  IIR LPF (alpha=1/8, cutoff ~4 Hz at 100 Hz tick rate), calibration
  with ±500 mV offset clamp, 3S/4S auto-detection, 1 Hz USART publish
- include/jlink.h + src/jlink.c: Add JLINK_TLM_BATTERY (0x82) telemetry
  type and jlink_tlm_battery_t (10-byte packed struct), implement
  jlink_send_battery_telemetry() using CRC16-XModem framing
- include/power_mgmt.h + src/power_mgmt.c: Add
  power_mgmt_notify_battery() — triggers STOP-mode sleep when Vbat
  sustains critical level (Issue #467)
- test/test_battery_adc.c: 27 unit tests (27/27 passing): voltage
  conversion, calibration offset/scale, IIR LPF convergence, SoC
  estimation (3S/4S), low/critical flags, PM notification timing,
  calibration reset, publish rate-limiting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:01:02 -05:00
19a30a1c4f feat: PID auto-tune for balance mode (Issue #531)
Implement Ziegler-Nichols relay feedback auto-tuning with flash persistence:

Firmware (STM32F722):
- pid_flash.c/h: erase+write Kp/Ki/Kd to flash sector 7 (0x0807FFC0),
  magic-validated; load on boot to restore saved tune
- jlink.h: add JLINK_CMD_PID_SAVE (0x0A) and JLINK_TLM_PID_RESULT (0x83)
  with jlink_tlm_pid_result_t struct and pid_save_req flag in JLinkState
- jlink.c: dispatch JLINK_CMD_PID_SAVE -> pid_save_req; add
  jlink_send_pid_result() to confirm flash write outcome over USART1
- main.c: load saved PID from flash after balance_init(); handle
  pid_save_req in main loop (disarmed-only, erase stalls CPU ~1s)

Jetson ROS2 (saltybot_pid_autotune):
- pid_autotune_node.py: add Ki to Ziegler-Nichols formula (ZN PID:
  Kp=0.6Ku, Ki=1.2Ku/Tu, Kd=0.075KuTu); add JLink serial client that
  sends JLINK_CMD_PID_SET + JLINK_CMD_PID_SAVE after tuning completes
- autotune_config.yaml: add jlink_serial_port and jlink_baud_rate params

Trigger: ros2 service call /saltybot/autotune_pid std_srvs/srv/Trigger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 09:56:19 -05:00
f71fdae747 feat: Depth-to-costmap plugin for RealSense D435i (Issue #532)
Add saltybot_depth_costmap — a Nav2 costmap2d plugin that converts
D435i depth images directly into obstacle markings on both local and
global costmaps.

Pipeline:
  1. Subscribe to /camera/depth/image_rect_raw (16UC1 mm) + camera_info
  2. Back-project depth pixels to 3D using pinhole camera intrinsics
  3. Transform points to costmap global_frame via TF2
  4. Apply configurable height filter (min_height..max_height above ground)
  5. Mark obstacle cells as LETHAL_OBSTACLE
  6. Inflate neighbours within inflation_radius as INSCRIBED_INFLATED_OBSTACLE

Parameters:
  min_height: 0.05 m       — floor clearance (ignores ground returns)
  max_height: 0.80 m       — ceiling cutoff (ignores lights/ceiling)
  obstacle_range: 3.5 m    — max marking distance from camera
  clearing_range: 4.0 m    — max distance processed at all
  inflation_radius: 0.10 m — in-layer inflation (works before inflation_layer)
  downsample_factor: 4     — process 1 of N rows+cols (~19k pts @ 640×480)

Integration (#478):
  - Added depth_costmap_layer to local_costmap plugins list
  - Added depth_costmap_layer to global_costmap plugins list
  - Plugin registered via pluginlib (plugin.xml)

Files:
  jetson/ros2_ws/src/saltybot_depth_costmap/
    CMakeLists.txt, package.xml, plugin.xml
    include/saltybot_depth_costmap/depth_costmap_layer.hpp
    src/depth_costmap_layer.cpp
  jetson/ros2_ws/src/saltybot_bringup/config/nav2_params.yaml (updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 09:52:18 -05:00
916ad36ad5 feat(webui): teleop web interface with live camera stream (Issue #534)
Adds TeleopWebUI component — a dedicated browser-based remote control
panel combining live video and joystick teleoperation in one view:

- Live camera stream (front/rear/left/right) via rosbridge CompressedImage
- Virtual joystick (canvas-based, touch + mouse, 10% deadzone)
- WASD / arrow-key keyboard fallback, Space for quick stop
- Speed presets: SLOW (20%), NORMAL (50%), FAST (100%)
- Latching E-stop button with pulsing visual indicator
- Real-time linear/angular velocity display
- Mobile-responsive: stacks vertically on small screens, side-by-side on lg+
- Added TELEOP tab group → Drive tab in App.jsx

Topics: /camera/<name>/image_raw/compressed (subscribe)
        /cmd_vel geometry_msgs/Twist (publish)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 09:51:14 -05:00
fc43135144 feat: Spring-loaded phone mount bracket for T-slot rail (Issue #535)
Parametric OpenSCAD design for 2020 T-slot rail phone mount bracket.
Adjustable width 60-85mm, spring-loaded cam quick-release lever,
vibration-dampening flexure rib grip pads. PETG 3D-printable, no supports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 09:49:10 -05:00
e67783f313 Merge remote-tracking branch 'origin/sl-android/issue-521-esc-debug-cleanup' 2026-03-06 23:34:45 -05:00
68b6410b24 Merge remote-tracking branch 'origin/sl-jetson/issue-523-motor-daemon' 2026-03-06 23:34:45 -05:00
5b7ee63d1e Merge remote-tracking branch 'origin/sl-controls/issue-522-usart6-truncation' 2026-03-06 23:34:45 -05:00
4f33e4e88d Merge pull request 'fix: USB CDC TX investigation (Issue #524)' (#525) from sl-mechanical/issue-524-usb-cdc-tx into main 2026-03-06 23:34:17 -05:00
54bc2509c1 Merge pull request 'fix: IMU calibration (Issue #520)' (#530) from sl-firmware/issue-520-imu-calibration into main 2026-03-06 23:34:16 -05:00
b0a5041261 fix: MPU6000 IMU calibration SPI/DCache issue (Issue #520)
Three bugs prevented mpu6000_is_calibrated() from returning true,
blocking arming and balance mode:

1. WHO_AM_I single-attempt: one SPI glitch returning 0x00 caused
   icm42688_init() to return -128, skipping mpu6000_calibrate()
   entirely. Fix: retry WHO_AM_I up to 3 times with 10ms gaps.

2. icm42688_read() rx[15] uninitialized: if HAL_SPI_TransmitReceive()
   failed, garbage stack data was accumulated as gyro bias. Fix: zero-
   init rx[15] so failed transfers produce zero data.

3. mpu6000_calibrate() raw uninitialized: UB if icm42688_read() is
   a no-op (imu_type mismatch). Fix: zero-init raw each iteration.

Also add SCB_InvalidateDCache_by_Addr() on SPI rx buffers in rreg()
and icm42688_read() for DCache coherency. Currently a no-op (DCache
is not enabled), but required if SCB_EnableDCache() is added — stack
buffers in SRAM2 are in the cacheable memory region on STM32F7.

Fix misleading DCache comment in icm42688.c (claimed DCache was
disabled by main.c; actually SCB_EnableDCache() is never called).

Build: 59904 bytes Flash (+512), 17100 bytes RAM — SUCCESS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:14:49 -05:00
7141e12320 feat: Integration test suite expanded (Issue #504) - resolve conflicts 2026-03-06 23:10:42 -05:00
sl-android
d5246fe3a8 chore: Guard ESC debug output behind compile flag (Issue #521)
- esc_hoverboard.c: huart2 static in production; non-static only under
  #ifdef DEBUG_MOTOR_TEST (needed by R command in jetson_uart.c)
- esc_hoverboard.c: UART5 diagnostic in hoverboard_backend_init() and
  per-packet printf in hoverboard_backend_send() guarded by same flag
- esc_hoverboard.c: #include <stdio.h> also guarded (not needed in production)
- jetson_uart.c: R (baud sweep) and X (GPIO test) commands guarded by
  #ifdef DEBUG_MOTOR_TEST — not compiled into production firmware

Production build: no debug output, static huart2, no R/X commands.
Debug build: define DEBUG_MOTOR_TEST to re-enable all diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:07:36 -05:00
7ad8c82da6 feat: Orin motor control daemon (Issue #523)
Add saltybot_motor_daemon ROS2 package — Python daemon that subscribes
to /cmd_vel and drives the FC via W<speed>,<steer>\n over /dev/ttyTHS1
at 921600 baud.

- motor_daemon_node.py: 50 Hz fixed-rate TX, 200ms safety watchdog,
  Twist→ESC conversion (±1000 range), FC ack parsing (W:<s>,<st>),
  periodic ? status query, /diagnostics publisher, auto-reconnect
- config/motor_daemon_params.yaml: all tunable params with comments
- launch/motor_daemon.launch.py: parameterised launch file
- test/test_motor_daemon.py: 25 unit tests (all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:04:07 -05:00
dd4eabf218 fix: Investigate USB CDC TX failure (Issue #524)
Root causes confirmed from code audit:
1. DCache coherency: USB OTG FS reads physical SRAM while CPU writes through
   DCache. Fix: MPU Region 0 marks 512B aligned USB buffer struct non-cacheable
   (TEX=1, C=0, B=0) before HAL_PCD_Init(). DCache stays enabled globally.
2. IWDG ordering: safety_init() (IWDG start) deferred after all peripheral inits
   to avoid watchdog reset during mpu6000_calibrate() (~510ms blocking).

DMA conflicts, GPIO conflicts, clock tree, and interrupt priorities all ruled out
with evidence. Full findings documented in USB_CDC_BUG.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:01:00 -05:00
669 changed files with 71045 additions and 4124 deletions

View File

@ -0,0 +1,162 @@
# .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,7 +7,11 @@ 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)
- Sent via USB CDC to the STM32 firmware <<<<<<< HEAD
- Sent via USB CDC to the ESP32 BALANCE firmware
=======
- Sent via USB Serial (CH343) to the ESP32-S3 firmware
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- Robot arms after ARMING_HOLD_MS (~500ms) safety hold period - Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
- Works even when RC is not connected or not armed - Works even when RC is not connected or not armed
@ -42,7 +46,11 @@ The robot can now be armed and operated autonomously from the Jetson without req
## Command Protocol ## Command Protocol
### From Jetson to STM32 (USB CDC) <<<<<<< HEAD
### 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)
@ -52,7 +60,11 @@ 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)
``` ```
### From STM32 to Jetson (USB CDC) <<<<<<< HEAD
### From ESP32 BALANCE to Jetson (USB CDC)
=======
### From ESP32-S3 to Jetson (USB Serial (CH343))
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
Motor commands are gated by `bal.state == BALANCE_ARMED`: Motor commands are gated by `bal.state == BALANCE_ARMED`:
- When ARMED: Motor commands sent every 20ms (50 Hz) - When ARMED: Motor commands sent every 20ms (50 Hz)
- When DISARMED: Zero sent every 20ms (prevents ESC timeout) - When DISARMED: Zero sent every 20ms (prevents ESC timeout)

View File

@ -1,17 +1,36 @@
# SaltyLab Firmware — Agent Playbook # SaltyLab Firmware — Agent Playbook
## Project ## Project
Self-balancing two-wheeled robot: STM32F722 flight controller, hoverboard hub motors, Jetson Nano for AI/SLAM. <<<<<<< 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.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Team ## Team
| Agent | Role | Focus | | Agent | Role | Focus |
|-------|------|-------| |-------|------|-------|
| **sl-firmware** | Embedded Firmware Lead | STM32 HAL, USB CDC debugging, SPI/UART, PlatformIO, DFU bootloader | <<<<<<< 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-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 Nano, 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 CDC 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).
## Repo Structure ## Repo Structure
- `projects/saltybot/SALTYLAB.md` — Design doc - `projects/saltybot/SALTYLAB.md` — Design doc
@ -29,11 +48,11 @@ USB CDC TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG order
| `saltyrover-dev` | Integration — rover variant | | `saltyrover-dev` | Integration — rover variant |
| `saltytank` | Stable — tracked tank variant | | `saltytank` | Stable — tracked tank variant |
| `saltytank-dev` | Integration — tank variant | | `saltytank-dev` | Integration — tank variant |
| `main` | Shared code only (IMU drivers, USB CDC, balance core, safety) | | `main` | Shared code only (IMU drivers, USB Serial (CH343), balance core, safety) |
### Rules ### Rules
- Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev` - Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev`
- Shared/infrastructure code (IMU drivers, USB CDC, balance core, safety) goes in `main` - Shared/infrastructure code (IMU drivers, USB Serial (CH343), balance core, safety) goes in `main`
- Variant-specific code (motor topology, kinematics, config) goes in variant branches - Variant-specific code (motor topology, kinematics, config) goes in variant branches
- Stable branches get promoted from `-dev` after review and hardware testing - Stable branches get promoted from `-dev` after review and hardware testing
- **Current SaltyLab team** works against `saltylab-dev` - **Current SaltyLab team** works against `saltylab-dev`

52
TEAM.md
View File

@ -1,12 +1,22 @@
# SaltyLab — Ideal Team # SaltyLab — Ideal Team
## Project ## Project
Self-balancing two-wheeled robot using a drone flight controller (STM32F722), hoverboard hub motors, and eventually a Jetson Nano for AI/SLAM. <<<<<<< 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.
## 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 CDC bug - **Firmware:** Balance PID + hoverboard ESC protocol written, but blocked by USB Serial (CH343) bug
- **Blocker:** USB CDC TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB OTG FS — see `USB_CDC_BUG.md` - **Blocker:** USB Serial (CH343) TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB on ESP32-S3 — see `legacy/stm32/USB_CDC_BUG.md` for historical context
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -14,18 +24,30 @@ Self-balancing two-wheeled robot using a drone flight controller (STM32F722), ho
### 1. Embedded Firmware Engineer (Lead) ### 1. Embedded Firmware Engineer (Lead)
**Must-have:** **Must-have:**
- Deep STM32 HAL experience (F7 series specifically) <<<<<<< HEAD
- Deep ESP32 (Arduino/ESP-IDF) or STM32 HAL experience
- USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts) - USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts)
- SPI + UART + USB coexistence on STM32 - SPI + UART + USB coexistence on ESP32
- PlatformIO or bare-metal STM32 toolchain - PlatformIO or bare-metal ESP32 toolchain
- DFU bootloader implementation - DFU bootloader implementation
=======
- Deep ESP-IDF experience (ESP32-S3 specifically)
- USB Serial (CH343) / UART debugging on ESP32-S3
- SPI + UART + USB coexistence on ESP32-S3
- ESP-IDF / Arduino-ESP32 toolchain
- OTA firmware update implementation
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
**Nice-to-have:** **Nice-to-have:**
- Betaflight/iNav/ArduPilot codebase familiarity - 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)
**Why:** The immediate blocker is a USB peripheral conflict. Need someone who's debugged STM32 USB issues before — this is not a software logic bug, it's a hardware peripheral interaction issue. <<<<<<< 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.
>>>>>>> 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:**
@ -43,7 +65,7 @@ Self-balancing two-wheeled robot using a drone flight controller (STM32F722), ho
### 3. Perception / SLAM Engineer (Phase 2) ### 3. Perception / SLAM Engineer (Phase 2)
**Must-have:** **Must-have:**
- Jetson Nano / NVIDIA Jetson platform - Jetson Orin Nano Super / NVIDIA Jetson platform
- Intel RealSense D435i depth camera - Intel RealSense D435i depth camera
- RPLIDAR integration - RPLIDAR integration
- SLAM (ORB-SLAM3, RTAB-Map, or similar) - SLAM (ORB-SLAM3, RTAB-Map, or similar)
@ -54,19 +76,23 @@ Self-balancing two-wheeled robot using a drone flight controller (STM32F722), ho
- Obstacle avoidance - Obstacle avoidance
- Nav2 stack - Nav2 stack
**Why:** Phase 2 goal is autonomous navigation. Jetson Nano with RealSense + RPLIDAR for indoor mapping and person following. **Why:** Phase 2 goal is autonomous navigation. Jetson Orin Nano Super with RealSense + RPLIDAR for indoor mapping and person following.
--- ---
## Hardware Reference ## Hardware Reference
| Component | Details | | Component | Details |
|-----------|---------| |-----------|---------|
| FC | MAMBA F722S (STM32F722RET6, MPU6000) | <<<<<<< HEAD
| 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 | | 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 |
| AI Brain | Jetson Nano + Noctua fan | | AI Brain | Jetson Orin Nano Super + Noctua fan |
| Depth | Intel RealSense D435i | | Depth | Intel RealSense D435i |
| LIDAR | RPLIDAR A1M8 | | LIDAR | RPLIDAR A1M8 |
| Spare IMUs | BNO055, MPU6050 | | Spare IMUs | BNO055, MPU6050 |
@ -74,4 +100,4 @@ Self-balancing two-wheeled robot using a drone flight controller (STM32F722), ho
## 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: `USB_CDC_BUG.md` - Bug doc: `legacy/stm32/USB_CDC_BUG.md` (archived — STM32 era)

View File

@ -1,44 +0,0 @@
# USB CDC TX Bug — 2026-02-28
## Problem
Balance firmware produces no USB CDC output. Minimal "hello" test firmware works fine.
## What Works
- **Test firmware** (just sends `{"hello":N}` at 10Hz after 3s delay): **DATA FLOWS**
- USB enumeration works in both cases (port appears as `/dev/cu.usbmodemSALTY0011`)
- DFU reboot via RTC backup register works (Betaflight-proven pattern)
## What Doesn't Work
- **Balance firmware**: port opens, no data ever arrives
- Tried: removing init transmit, 3s boot delay, TxState recovery, DTR detection, streaming flags
- None of it helps
## Key Difference Between Working & Broken
- **Working test**: main.c only includes USB CDC headers, HAL, string, stdio
- **Balance firmware**: includes icm42688.h, bmp280.h, balance.h, hoverboard.h, config.h, status.h
- Balance firmware inits SPI1 (IMU), USART2 (hoverboard), GPIO (LEDs, buzzer)
- Likely culprit: **peripheral init (SPI/UART/GPIO) is interfering with USB OTG FS**
## Suspected Root Cause
One of the additional peripheral inits (SPI1 for IMU, USART2 for hoverboard ESC, or GPIO for status LEDs) is likely conflicting with the USB OTG FS peripheral — either a clock conflict, GPIO pin conflict, or interrupt priority issue.
## Hardware
- MAMBA F722S FC (STM32F722RET6)
- Betaflight target: DIAT-MAMBAF722_2022B
- IMU: MPU6000 on SPI1 (PA4/PA5/PA6/PA7)
- USB: OTG FS (PA11/PA12)
- Hoverboard ESC: USART2 (PA2/PA3)
- LEDs: PC14, PC15
- Buzzer: PB2
## Files
- PlatformIO project: `~/Projects/saltylab-firmware/` on mbpm4 (192.168.87.40)
- Working test: was in src/main.c (replaced with balance code)
- Balance main.c backup: src/main.c.bak
- CDC implementation: lib/USB_CDC/src/usbd_cdc_if.c
## To Debug
1. Add peripherals one at a time to the test firmware to find which one breaks CDC TX
2. Check for GPIO pin conflicts with USB OTG FS (PA11/PA12)
3. Check interrupt priorities — USB OTG FS IRQ might be getting starved
4. Check if DCache (disabled via SCB_DisableDCache) is needed for USB DMA

46
android/build.gradle Normal file
View File

@ -0,0 +1,46 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 34
namespace 'com.saltylab.uwbtag'
defaultConfig {
applicationId "com.saltylab.uwbtag"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
}
}
buildFeatures {
viewBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'com.google.code.gson:gson:2.10.1'
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions (API 31+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Legacy BLE (API < 31) -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="true"
android:label="UWB Tag Config"
android:theme="@style/Theme.MaterialComponents.DayNight.DarkActionBar">
<activity
android:name=".UwbTagBleActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,444 @@
package com.saltylab.uwbtag
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.textfield.TextInputEditText
import com.google.gson.Gson
import com.saltylab.uwbtag.databinding.ActivityUwbTagBleBinding
import java.util.UUID
// ---------------------------------------------------------------------------
// GATT service / characteristic UUIDs
// ---------------------------------------------------------------------------
private val SERVICE_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef0")
private val CHAR_CONFIG_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef1") // read/write JSON config
private val CHAR_STATUS_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef2") // notify: tag status string
private val CHAR_BATT_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef3") // notify: battery %
private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// BLE scan timeout
private const val SCAN_TIMEOUT_MS = 15_000L
// Permissions request code
private const val REQ_PERMISSIONS = 1001
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
data class TagConfig(
val tag_name: String = "UWB_TAG_0001",
val sleep_timeout_s: Int = 300,
val display_brightness: Int = 50,
val uwb_channel: Int = 9,
val ranging_interval_ms: Int = 100,
val battery_report: Boolean = true
)
data class ScannedDevice(
val name: String,
val address: String,
var rssi: Int,
val device: BluetoothDevice
)
// ---------------------------------------------------------------------------
// RecyclerView adapter for scanned devices
// ---------------------------------------------------------------------------
class DeviceAdapter(
private val onConnect: (ScannedDevice) -> Unit
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
private val items = mutableListOf<ScannedDevice>()
fun update(device: ScannedDevice) {
val idx = items.indexOfFirst { it.address == device.address }
if (idx >= 0) {
items[idx] = device
notifyItemChanged(idx)
} else {
items.add(device)
notifyItemInserted(items.size - 1)
}
}
fun clear() {
items.clear()
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_ble_device, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position])
override fun getItemCount() = items.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val tvName = view.findViewById<TextView>(R.id.tvDeviceName)
private val tvAddress = view.findViewById<TextView>(R.id.tvDeviceAddress)
private val tvRssi = view.findViewById<TextView>(R.id.tvRssi)
private val btnConn = view.findViewById<Button>(R.id.btnConnect)
fun bind(item: ScannedDevice) {
tvName.text = item.name
tvAddress.text = item.address
tvRssi.text = "${item.rssi} dBm"
btnConn.setOnClickListener { onConnect(item) }
}
}
}
// ---------------------------------------------------------------------------
// Activity
// ---------------------------------------------------------------------------
@SuppressLint("MissingPermission") // permissions checked at runtime before any BLE call
class UwbTagBleActivity : AppCompatActivity() {
private lateinit var binding: ActivityUwbTagBleBinding
private val gson = Gson()
private val mainHandler = Handler(Looper.getMainLooper())
// BLE
private val btManager by lazy { getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager }
private val btAdapter by lazy { btManager.adapter }
private var bleScanner: BluetoothLeScanner? = null
private var gatt: BluetoothGatt? = null
private var configChar: BluetoothGattCharacteristic? = null
private var statusChar: BluetoothGattCharacteristic? = null
private var battChar: BluetoothGattCharacteristic? = null
private var isScanning = false
private val deviceAdapter = DeviceAdapter(onConnect = ::connectToDevice)
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityUwbTagBleBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.rvDevices.layoutManager = LinearLayoutManager(this)
binding.rvDevices.adapter = deviceAdapter
binding.btnScan.setOnClickListener {
if (isScanning) stopScan() else startScanIfPermitted()
}
binding.btnDisconnect.setOnClickListener { disconnectGatt() }
binding.btnReadConfig.setOnClickListener { readConfig() }
binding.btnWriteConfig.setOnClickListener { writeConfig() }
requestBlePermissions()
}
override fun onDestroy() {
super.onDestroy()
stopScan()
disconnectGatt()
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
private fun requestBlePermissions() {
val needed = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN))
needed += Manifest.permission.BLUETOOTH_SCAN
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT))
needed += Manifest.permission.BLUETOOTH_CONNECT
} else {
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION))
needed += Manifest.permission.ACCESS_FINE_LOCATION
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQ_PERMISSIONS)
}
}
private fun hasPermission(perm: String) =
ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_PERMISSIONS &&
grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
toast("BLE permissions required")
}
}
// ---------------------------------------------------------------------------
// BLE Scan
// ---------------------------------------------------------------------------
private fun startScanIfPermitted() {
if (btAdapter?.isEnabled != true) { toast("Bluetooth is off"); return }
bleScanner = btAdapter.bluetoothLeScanner
val filter = ScanFilter.Builder()
.setDeviceNamePattern("UWB_TAG_.*".toRegex().toPattern())
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
deviceAdapter.clear()
bleScanner?.startScan(listOf(filter), settings, scanCallback)
isScanning = true
binding.btnScan.text = "Stop"
binding.tvScanStatus.text = "Scanning…"
mainHandler.postDelayed({ stopScan() }, SCAN_TIMEOUT_MS)
}
private fun stopScan() {
bleScanner?.stopScan(scanCallback)
isScanning = false
binding.btnScan.text = "Scan"
binding.tvScanStatus.text = "Scan stopped"
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name = result.device.name ?: return
if (!name.startsWith("UWB_TAG_")) return
val dev = ScannedDevice(
name = name,
address = result.device.address,
rssi = result.rssi,
device = result.device
)
mainHandler.post { deviceAdapter.update(dev) }
}
override fun onScanFailed(errorCode: Int) {
mainHandler.post {
binding.tvScanStatus.text = "Scan failed (code $errorCode)"
isScanning = false
binding.btnScan.text = "Scan"
}
}
}
// ---------------------------------------------------------------------------
// GATT Connection
// ---------------------------------------------------------------------------
private fun connectToDevice(scanned: ScannedDevice) {
stopScan()
binding.tvScanStatus.text = "Connecting to ${scanned.name}"
gatt = scanned.device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
private fun disconnectGatt() {
gatt?.disconnect()
gatt?.close()
gatt = null
configChar = null
statusChar = null
battChar = null
mainHandler.post {
binding.cardConfig.visibility = View.GONE
binding.tvScanStatus.text = "Disconnected"
}
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
mainHandler.post { binding.tvScanStatus.text = "Connected — discovering services…" }
g.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
mainHandler.post {
binding.cardConfig.visibility = View.GONE
binding.tvScanStatus.text = "Disconnected"
toast("Tag disconnected")
}
gatt?.close()
gatt = null
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
mainHandler.post { toast("Service discovery failed") }
return
}
val service = g.getService(SERVICE_UUID)
if (service == null) {
mainHandler.post { toast("UWB config service not found on tag") }
return
}
configChar = service.getCharacteristic(CHAR_CONFIG_UUID)
statusChar = service.getCharacteristic(CHAR_STATUS_UUID)
battChar = service.getCharacteristic(CHAR_BATT_UUID)
// Subscribe to status notifications
statusChar?.let { enableNotifications(g, it) }
battChar?.let { enableNotifications(g, it) }
// Initial config read
configChar?.let { g.readCharacteristic(it) }
mainHandler.post {
val devName = g.device.name ?: g.device.address
binding.tvConnectedName.text = "Connected: $devName"
binding.cardConfig.visibility = View.VISIBLE
binding.tvScanStatus.text = "Connected to $devName"
}
}
override fun onCharacteristicRead(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (status != BluetoothGatt.GATT_SUCCESS) return
if (characteristic.uuid == CHAR_CONFIG_UUID) {
val json = characteristic.value?.toString(Charsets.UTF_8) ?: return
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
mainHandler.post { populateFields(cfg) }
}
}
// API 33+ callback
override fun onCharacteristicRead(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int
) {
if (status != BluetoothGatt.GATT_SUCCESS) return
if (characteristic.uuid == CHAR_CONFIG_UUID) {
val json = value.toString(Charsets.UTF_8)
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
mainHandler.post { populateFields(cfg) }
}
}
override fun onCharacteristicWrite(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
val msg = if (status == BluetoothGatt.GATT_SUCCESS) "Config written" else "Write failed ($status)"
mainHandler.post { toast(msg) }
}
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
val value = characteristic.value ?: return
handleNotification(characteristic.uuid, value)
}
// API 33+ callback
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
handleNotification(characteristic.uuid, value)
}
}
// ---------------------------------------------------------------------------
// Notification helpers
// ---------------------------------------------------------------------------
private fun enableNotifications(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
g.setCharacteristicNotification(char, true)
val descriptor = char.getDescriptor(CCCD_UUID) ?: return
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(descriptor)
}
private fun handleNotification(uuid: UUID, value: ByteArray) {
val text = value.toString(Charsets.UTF_8)
mainHandler.post {
when (uuid) {
CHAR_STATUS_UUID -> binding.tvTagStatus.text = "Status: $text"
CHAR_BATT_UUID -> {
val pct = text.toIntOrNull() ?: return@post
binding.tvTagStatus.text = binding.tvTagStatus.text.toString()
.replace(Regex("\\| Batt:.*"), "")
.trimEnd() + " | Batt: $pct%"
}
}
}
}
// ---------------------------------------------------------------------------
// Config read / write
// ---------------------------------------------------------------------------
private fun readConfig() {
val g = gatt ?: run { toast("Not connected"); return }
val c = configChar ?: run { toast("Config char not found"); return }
g.readCharacteristic(c)
}
private fun writeConfig() {
val g = gatt ?: run { toast("Not connected"); return }
val c = configChar ?: run { toast("Config char not found"); return }
val cfg = buildConfigFromFields()
val json = gson.toJson(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(c, json.toByteArray(Charsets.UTF_8),
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
@Suppress("DEPRECATION")
c.value = json.toByteArray(Charsets.UTF_8)
@Suppress("DEPRECATION")
g.writeCharacteristic(c)
}
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
private fun populateFields(cfg: TagConfig) {
binding.etTagName.setText(cfg.tag_name)
binding.etSleepTimeout.setText(cfg.sleep_timeout_s.toString())
binding.etBrightness.setText(cfg.display_brightness.toString())
binding.etUwbChannel.setText(cfg.uwb_channel.toString())
binding.etRangingInterval.setText(cfg.ranging_interval_ms.toString())
binding.switchBatteryReport.isChecked = cfg.battery_report
}
private fun buildConfigFromFields() = TagConfig(
tag_name = binding.etTagName.text?.toString() ?: "UWB_TAG_0001",
sleep_timeout_s = binding.etSleepTimeout.text?.toString()?.toIntOrNull() ?: 300,
display_brightness = binding.etBrightness.text?.toString()?.toIntOrNull() ?: 50,
uwb_channel = binding.etUwbChannel.text?.toString()?.toIntOrNull() ?: 9,
ranging_interval_ms = binding.etRangingInterval.text?.toString()?.toIntOrNull() ?: 100,
battery_report = binding.switchBatteryReport.isChecked
)
private fun toast(msg: String) =
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,238 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:title="UWB Tag BLE Config" />
<!-- Scan controls -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<Button
android:id="@+id/btnScan"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Scan" />
<TextView
android:id="@+id/tvScanStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:text="Tap Scan to find UWB tags"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
</LinearLayout>
<!-- Scan results list -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:text="Nearby Tags"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvDevices"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp"
android:clipToPadding="false" />
<!-- Connected device config panel -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardConfig"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:visibility="gone"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvConnectedName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Connected: —"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textStyle="bold" />
<Button
android:id="@+id/btnDisconnect"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Disconnect" />
</LinearLayout>
<!-- tag_name -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Tag Name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTagName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- sleep_timeout_s and uwb_channel (row) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:hint="Sleep Timeout (s)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSleepTimeout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:hint="UWB Channel">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUwbChannel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<!-- display_brightness and ranging_interval_ms (row) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:hint="Brightness (0-100)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etBrightness"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:hint="Ranging Interval (ms)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etRangingInterval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<!-- battery_report toggle -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchBatteryReport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Battery Reporting" />
<!-- Action buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Button
android:id="@+id/btnReadConfig"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Read" />
<Button
android:id="@+id/btnWriteConfig"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Write" />
</LinearLayout>
<!-- Status notifications from tag -->
<TextView
android:id="@+id/tvTagStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#1A000000"
android:fontFamily="monospace"
android:padding="8dp"
android:text="Tag status: —"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardElevation="2dp"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvDeviceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="UWB_TAG_XXXX"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDeviceAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="XX:XX:XX:XX:XX:XX"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
</LinearLayout>
<TextView
android:id="@+id/tvRssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-70 dBm"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="?attr/colorSecondary" />
<Button
android:id="@+id/btnConnect"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Connect" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

118
cad/assembly.scad Normal file
View File

@ -0,0 +1,118 @@
// ============================================
// SaltyLab Full Assembly Visualization
// Shows all parts in position on 2020 spine
// ============================================
include <dimensions.scad>
// Spine height
spine_h = 500;
// Component heights (center of each mount on spine)
h_motor = 0;
h_battery = 50;
h_esc = 100;
h_fc = 170;
h_jetson = 250;
h_realsense = 350;
h_lidar = 430;
// Colors for visualization
module spine() {
color("silver")
translate([-extrusion_w/2, -extrusion_w/2, 0])
cube([extrusion_w, extrusion_w, spine_h]);
}
module wheel(side) {
color("DimGray")
translate([side * 140, 0, 0])
rotate([0, 90, 0])
cylinder(d=200, h=50, center=true, $fn=60);
}
// --- Assembly ---
// Spine
spine();
// Wheels
wheel(-1);
wheel(1);
// Motor mount plate (at base)
color("DodgerBlue", 0.7)
translate([0, 0, h_motor])
import("motor_mount_plate.stl");
// Battery shelf
color("OrangeRed", 0.7)
translate([0, 0, h_battery])
rotate([0, 0, 0])
cube([180, 80, 40], center=true);
// ESC
color("Green", 0.7)
translate([0, 0, h_esc])
cube([80, 50, 15], center=true);
// FC (tiny!)
color("Purple", 0.9)
translate([0, 0, h_fc])
cube([36, 36, 5], center=true);
// Jetson Orin Nano Super
color("LimeGreen", 0.7)
translate([0, 0, h_jetson])
cube([100, 80, 29], center=true);
// RealSense D435i
color("Gray", 0.8)
translate([0, -40, h_realsense])
cube([90, 25, 25], center=true);
// RPLIDAR A1
color("Cyan", 0.7)
translate([0, 0, h_lidar])
cylinder(d=70, h=41, center=true, $fn=40);
// Kill switch (accessible on front)
color("Red")
translate([0, -60, h_esc + 30])
cylinder(d=22, h=10, $fn=30);
// LED ring
color("White", 0.3)
translate([0, 0, h_jetson - 20])
difference() {
cylinder(d=120, h=15, $fn=60);
translate([0, 0, -1])
cylinder(d=110, h=17, $fn=60);
}
// Bumpers
color("Orange", 0.5) {
translate([0, -75, 25])
cube([350, 30, 50], center=true);
translate([0, 75, 25])
cube([350, 30, 50], center=true);
}
// Handle (top)
color("Yellow", 0.7)
translate([0, 0, spine_h + 10])
cube([100, 20, 25], center=true);
// Tether point
color("Red", 0.8)
translate([0, 0, spine_h - 20]) {
difference() {
cylinder(d=30, h=8, $fn=30);
translate([0, 0, -1])
cylinder(d=15, h=10, $fn=30);
}
}
echo("=== SaltyLab Assembly ===");
echo(str("Total height: ", spine_h + 30, "mm"));
echo(str("Width (axle-axle): ", 280 + 50*2, "mm"));
echo(str("Depth: ~", 150, "mm"));

77
cad/battery_shelf.scad Normal file
View File

@ -0,0 +1,77 @@
// ============================================
// SaltyLab Battery Shelf
// 200×100×40mm PETG
// Holds 36V battery pack low on the frame
// Mounts to 2020 extrusion spine
// ============================================
include <dimensions.scad>
shelf_w = 200;
shelf_d = 100;
shelf_h = 40;
floor_h = 3; // Bottom plate
// Battery pocket (with tolerance)
pocket_w = batt_w + tol*2;
pocket_d = batt_d + tol*2;
pocket_h = batt_h + 5; // Slightly taller than battery
// Velcro strap slots
strap_w = 25;
strap_h = 3;
module battery_shelf() {
difference() {
union() {
// Floor
translate([-shelf_w/2, -shelf_d/2, 0])
cube([shelf_w, shelf_d, floor_h]);
// Walls (3 sides front open for wires)
// Left wall
translate([-shelf_w/2, -shelf_d/2, 0])
cube([wall, shelf_d, shelf_h]);
// Right wall
translate([shelf_w/2 - wall, -shelf_d/2, 0])
cube([wall, shelf_d, shelf_h]);
// Back wall
translate([-shelf_w/2, shelf_d/2 - wall, 0])
cube([shelf_w, wall, shelf_h]);
// Front lip (low, keeps battery from sliding out)
translate([-shelf_w/2, -shelf_d/2, 0])
cube([shelf_w, wall, 10]);
// 2020 extrusion mount tabs (top of back wall)
for (x = [-30, 30]) {
translate([x - 10, shelf_d/2 - wall, shelf_h - 15])
cube([20, wall + 10, 15]);
}
}
// Extrusion bolt holes (M5) through back mount tabs
for (x = [-30, 30]) {
translate([x, shelf_d/2 + 5, shelf_h - 7.5])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=wall + 15, $fn=30);
}
// Velcro strap slots (2x through floor for securing battery)
for (x = [-50, 50]) {
translate([x - strap_w/2, -20, -1])
cube([strap_w, strap_h, floor_h + 2]);
}
// Weight reduction holes in floor
for (x = [-30, 30]) {
translate([x, 0, -1])
cylinder(d=20, h=floor_h + 2, $fn=30);
}
// Wire routing slot (front wall, centered)
translate([-20, -shelf_d/2 - 1, floor_h])
cube([40, wall + 2, 15]);
}
}
battery_shelf();

75
cad/bumper.scad Normal file
View File

@ -0,0 +1,75 @@
// ============================================
// SaltyLab Bumper (Front/Rear)
// 350×50×30mm TPU
// Absorbs falls, protects frame and floor
// ============================================
include <dimensions.scad>
bumper_w = 350;
bumper_h = 50;
bumper_d = 30;
bumper_wall = 2.5;
// Honeycomb crush structure for energy absorption
hex_size = 8;
hex_wall = 1.2;
module honeycomb_cell(size, height) {
difference() {
cylinder(d=size, h=height, $fn=6);
translate([0, 0, -1])
cylinder(d=size - hex_wall*2, h=height + 2, $fn=6);
}
}
module bumper() {
difference() {
union() {
// Outer shell (curved front face)
hull() {
translate([-bumper_w/2, 0, 0])
cube([bumper_w, 1, bumper_h]);
translate([-bumper_w/2 + 10, bumper_d - 5, 5])
cube([bumper_w - 20, 1, bumper_h - 10]);
}
}
// Hollow interior (leave outer shell)
hull() {
translate([-bumper_w/2 + bumper_wall, bumper_wall, bumper_wall])
cube([bumper_w - bumper_wall*2, 1, bumper_h - bumper_wall*2]);
translate([-bumper_w/2 + 10 + bumper_wall, bumper_d - 5 - bumper_wall, 5 + bumper_wall])
cube([bumper_w - 20 - bumper_wall*2, 1, bumper_h - 10 - bumper_wall*2]);
}
// Mounting bolt holes (M5, through back face, 4 points)
for (x = [-120, -40, 40, 120]) {
translate([x, -1, bumper_h/2])
rotate([-90, 0, 0])
cylinder(d=m5_clear, h=10, $fn=25);
}
}
// Internal honeycomb ribs for crush absorption
intersection() {
// Bound to bumper volume
hull() {
translate([-bumper_w/2 + bumper_wall, bumper_wall, bumper_wall])
cube([bumper_w - bumper_wall*2, 1, bumper_h - bumper_wall*2]);
translate([-bumper_w/2 + 15, bumper_d - 8, 8])
cube([bumper_w - 30, 1, bumper_h - 16]);
}
// Honeycomb grid
for (x = [-170:hex_size*1.5:170]) {
for (z = [0:hex_size*1.3:60]) {
offset_x = (floor(z / (hex_size*1.3)) % 2) * hex_size * 0.75;
translate([x + offset_x, 0, z])
rotate([-90, 0, 0])
honeycomb_cell(hex_size, bumper_d);
}
}
}
}
bumper();

73
cad/dimensions.scad Normal file
View File

@ -0,0 +1,73 @@
// ============================================
// SaltyLab Common Dimensions & Constants
// ============================================
// --- 2020 Aluminum Extrusion ---
extrusion_w = 20;
extrusion_slot = 6; // T-slot width
extrusion_bore = 5; // Center bore M5
// --- Hub Motors (8" hoverboard) ---
motor_axle_dia = 12;
motor_axle_len = 45;
motor_axle_flat = 10; // Flat-to-flat if D-shaft
motor_body_dia = 200; // ~8 inches
motor_bolt_circle = 0; // Axle-only mount (clamp style)
// --- Drone FC (30.5mm standard) ---
fc_hole_spacing = 25.5; // GEP-F722 AIO v2 (not standard 30.5!)
fc_hole_dia = 3.2; // M3 clearance
fc_board_size = 36; // Typical FC PCB
fc_standoff_h = 5; // Rubber standoff height
// --- Jetson Orin Nano Super ---
jetson_w = 100;
jetson_d = 80;
jetson_h = 29; // With heatsink
jetson_hole_x = 86; // Mounting hole spacing X
jetson_hole_y = 58; // Mounting hole spacing Y
jetson_hole_dia = 2.7; // M2.5 clearance
// --- RealSense D435i ---
rs_w = 90;
rs_d = 25;
rs_h = 25;
rs_tripod_offset = 0; // 1/4-20 centered bottom
rs_mount_dia = 6.5; // 1/4-20 clearance
// --- RPLIDAR A1 ---
lidar_dia = 70;
lidar_h = 41;
lidar_mount_circle = 67; // Bolt circle diameter
lidar_hole_count = 4;
lidar_hole_dia = 2.7; // M2.5
// --- Kill Switch (22mm panel mount) ---
kill_sw_dia = 22;
kill_sw_depth = 35; // Behind-panel depth
// --- Battery (typical 36V hoverboard pack) ---
batt_w = 180;
batt_d = 80;
batt_h = 40;
// --- Hoverboard ESC ---
esc_w = 80;
esc_d = 50;
esc_h = 15;
// --- ESP32-C3 (typical dev board) ---
esp_w = 25;
esp_d = 18;
esp_h = 5;
// --- WS2812B strip ---
led_strip_w = 10; // 10mm wide strip
// --- General ---
wall = 3; // Default wall thickness
m3_clear = 3.2;
m3_insert = 4.2; // Heat-set insert hole
m25_clear = 2.7;
m5_clear = 5.3;
tol = 0.2; // Print tolerance per side

70
cad/esc_mount.scad Normal file
View File

@ -0,0 +1,70 @@
// ============================================
// SaltyLab ESC Mount
// 150×100×15mm PETG
// Hoverboard ESC, mounts to 2020 extrusion
// ============================================
include <dimensions.scad>
mount_w = 150;
mount_d = 100;
mount_h = 15;
base_h = 3;
module esc_mount() {
difference() {
union() {
// Base plate
translate([-mount_w/2, -mount_d/2, 0])
cube([mount_w, mount_d, base_h]);
// ESC retaining walls (low lip on 3 sides)
// Left
translate([-mount_w/2, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
// Right
translate([mount_w/2 - wall, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
// Back
translate([-mount_w/2, mount_d/2 - wall, 0])
cube([mount_w, wall, mount_h]);
// Front clips (snap-fit tabs to hold ESC)
for (x = [-30, 30]) {
translate([x - 5, -mount_d/2, 0])
cube([10, wall, mount_h]);
// Clip overhang
translate([x - 5, -mount_d/2, mount_h - 2])
cube([10, wall + 3, 2]);
}
// 2020 mount tabs (back)
for (x = [-25, 25]) {
translate([x - 10, mount_d/2 - wall, 0])
cube([20, wall + 8, base_h + 8]);
}
}
// Extrusion bolt holes (M5)
for (x = [-25, 25]) {
translate([x, mount_d/2 + 3, base_h + 4])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=wall + 12, $fn=30);
}
// Ventilation holes in base
for (x = [-40, -20, 0, 20, 40]) {
for (y = [-25, 0, 25]) {
translate([x, y, -1])
cylinder(d=8, h=base_h + 2, $fn=20);
}
}
// Wire routing slots (front and back)
translate([-15, -mount_d/2 - 1, base_h])
cube([30, wall + 2, 10]);
translate([-15, mount_d/2 - wall - 1, base_h])
cube([30, wall + 2, 10]);
}
}
esc_mount();

57
cad/esp32c3_mount.scad Normal file
View File

@ -0,0 +1,57 @@
// ============================================
// SaltyLab ESP32-C3 Mount
// 30×25×10mm PETG
// Tiny mount for LED controller MCU
// ============================================
include <dimensions.scad>
mount_w = 30;
mount_d = 25;
mount_h = 10;
base_h = 2;
module esp32c3_mount() {
difference() {
union() {
// Base
translate([-mount_w/2, -mount_d/2, 0])
cube([mount_w, mount_d, base_h]);
// Retaining walls (3 sides, front open for USB)
translate([-mount_w/2, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
translate([mount_w/2 - wall, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
translate([-mount_w/2, mount_d/2 - wall, 0])
cube([mount_w, wall, mount_h]);
// Clip tabs (front corners)
for (x = [-mount_w/2, mount_w/2 - wall]) {
translate([x, -mount_d/2, mount_h - 2])
cube([wall, 4, 2]);
}
// Zip-tie slot wings
for (x = [-mount_w/2 - 4, mount_w/2 + 1]) {
translate([x, -5, 0])
cube([3, 10, base_h]);
}
}
// Board pocket (recessed)
translate([-esp_w/2 - tol, -esp_d/2 - tol, base_h])
cube([esp_w + tol*2, esp_d + tol*2, mount_h]);
// Zip-tie slots
for (x = [-mount_w/2 - 4, mount_w/2 + 1]) {
translate([x, -2, -1])
cube([3, 4, base_h + 2]);
}
// USB port clearance (front)
translate([-5, -mount_d/2 - 1, base_h])
cube([10, wall + 2, 5]);
}
}
esp32c3_mount();

86
cad/fc_mount.scad Normal file
View File

@ -0,0 +1,86 @@
// ============================================
// SaltyLab Flight Controller Mount
// Vibration-isolated, 30.5mm pattern
// TPU dampers + PETG frame
// ============================================
include <dimensions.scad>
// FC mount attaches to 2020 extrusion via T-slot
// Rubber/TPU grommets isolate FC from frame vibration
mount_w = 45; // Overall width
mount_d = 45; // Overall depth
mount_h = 15; // Total height (base + standoffs)
base_h = 4; // Base plate thickness
// TPU grommet dimensions
grommet_od = 7;
grommet_id = 3.2; // M3 clearance
grommet_h = 5; // Soft mount height
module fc_mount() {
difference() {
union() {
// Base plate
translate([-mount_w/2, -mount_d/2, 0])
cube([mount_w, mount_d, base_h]);
// Standoff posts (PETG, FC sits on TPU grommets on top)
for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, 0])
cylinder(d=8, h=base_h + grommet_h, $fn=30);
}
}
// 2020 extrusion clamp tabs (sides)
for (side = [-1, 1]) {
translate([side * (extrusion_w/2 + wall), -15, 0])
cube([wall, 30, base_h + 10]);
}
}
// FC mounting holes (M3 through standoffs)
for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, -1])
cylinder(d=fc_hole_dia, h=base_h + grommet_h + 2, $fn=25);
}
}
// Extrusion channel (20mm wide slot through base)
translate([-extrusion_w/2 - tol, -20, -1])
cube([extrusion_w + tol*2, 40, base_h + 2]);
// Clamp bolt holes (M3, horizontal through side tabs)
for (side = [-1, 1]) {
translate([side * (extrusion_w/2 + wall + 1), 0, base_h + 5])
rotate([0, 90, 0])
cylinder(d=m3_clear, h=wall + 2, center=true, $fn=25);
}
// Center cutout for airflow / weight reduction
translate([0, 0, -1])
cylinder(d=15, h=base_h + 2, $fn=30);
}
}
// TPU grommet (print separately in TPU)
module tpu_grommet() {
difference() {
cylinder(d=grommet_od, h=grommet_h, $fn=30);
translate([0, 0, -1])
cylinder(d=grommet_id, h=grommet_h + 2, $fn=25);
}
}
// Show assembled
fc_mount();
// Show grommets in position (for visualization)
%for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, base_h])
tpu_grommet();
}
}

59
cad/handle.scad Normal file
View File

@ -0,0 +1,59 @@
// ============================================
// SaltyLab Carry Handle
// 150×30×30mm PETG
// Comfortable grip, mounts on top of spine
// ============================================
include <dimensions.scad>
handle_w = 150;
handle_h = 30;
grip_dia = 25; // Comfortable grip diameter
grip_len = 100; // Grip section length
module handle() {
difference() {
union() {
// Grip bar (rounded for comfort)
translate([-grip_len/2, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=grip_len, $fn=40);
// Left support leg
hull() {
translate([-handle_w/2, -10, 0])
cube([20, 20, 3]);
translate([-grip_len/2, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=5, $fn=40);
}
// Right support leg
hull() {
translate([handle_w/2 - 20, -10, 0])
cube([20, 20, 3]);
translate([grip_len/2 - 5, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=5, $fn=40);
}
}
// 2020 extrusion slot (center of base)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 5]);
// M5 bolt holes for extrusion (2x)
for (x = [-30, 30]) {
translate([x, 0, -1])
cylinder(d=m5_clear, h=5, $fn=25);
}
// Finger grooves on grip
for (x = [-30, -10, 10, 30]) {
translate([x, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia + 4, h=5, center=true, $fn=40);
}
}
}
handle();

69
cad/jetson_shelf.scad Normal file
View File

@ -0,0 +1,69 @@
// ============================================
// SaltyLab Jetson Orin Nano Super Shelf
// 120×100×15mm PETG
// Mounts Jetson Orin Nano Super to 2020 extrusion
// ============================================
include <dimensions.scad>
shelf_w = 120;
shelf_d = 100;
shelf_h = 15;
base_h = 3;
standoff_h = 8; // Clearance for Jetson underside components
module jetson_shelf() {
difference() {
union() {
// Base plate
translate([-shelf_w/2, -shelf_d/2, 0])
cube([shelf_w, shelf_d, base_h]);
// Jetson standoffs (M2.5, 86mm × 58mm pattern)
for (x = [-jetson_hole_x/2, jetson_hole_x/2]) {
for (y = [-jetson_hole_y/2, jetson_hole_y/2]) {
translate([x, y, 0])
cylinder(d=6, h=base_h + standoff_h, $fn=25);
}
}
// 2020 extrusion clamp (back edge)
translate([-15, shelf_d/2 - wall, 0])
cube([30, wall + 10, base_h + 12]);
// Side rails for Jetson alignment
for (x = [-jetson_w/2 - wall, jetson_w/2]) {
translate([x, -jetson_d/2, base_h + standoff_h])
cube([wall, jetson_d, 4]);
}
}
// Jetson M2.5 holes (through standoffs)
for (x = [-jetson_hole_x/2, jetson_hole_x/2]) {
for (y = [-jetson_hole_y/2, jetson_hole_y/2]) {
translate([x, y, -1])
cylinder(d=jetson_hole_dia, h=base_h + standoff_h + 2, $fn=25);
}
}
// Extrusion bolt hole (M5, through back clamp)
translate([0, shelf_d/2 + 3, base_h + 6])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=wall + 15, $fn=30);
// Extrusion channel slot
translate([-extrusion_w/2 - tol, shelf_d/2 - wall - 1, -1])
cube([extrusion_w + tol*2, wall + 2, base_h + 2]);
// Ventilation / cable routing
for (x = [-25, 0, 25]) {
translate([x, 0, -1])
cylinder(d=15, h=base_h + 2, $fn=25);
}
// USB/Ethernet/GPIO access cutouts (front edge)
translate([-jetson_w/2, -shelf_d/2 - 1, base_h])
cube([jetson_w, wall + 2, shelf_h]);
}
}
jetson_shelf();

View File

@ -0,0 +1,56 @@
// ============================================
// SaltyLab Kill Switch Mount
// 60×60×40mm PETG
// 22mm panel-mount emergency stop button
// Mounts to 2020 extrusion, easily reachable
// ============================================
include <dimensions.scad>
mount_w = 60;
mount_d = 60;
mount_h = 40;
panel_h = 3; // Panel face thickness
module kill_switch_mount() {
difference() {
union() {
// Main body (angled face for visibility)
hull() {
translate([-mount_w/2, 0, 0])
cube([mount_w, mount_d, 1]);
translate([-mount_w/2, 5, mount_h])
cube([mount_w, mount_d - 5, 1]);
}
// 2020 extrusion mount bracket (back)
translate([-15, mount_d, 0])
cube([30, 10, 20]);
}
// Kill switch hole (22mm, through angled face)
translate([0, mount_d/2, mount_h/2])
rotate([10, 0, 0]) // Slight angle for ergonomics
cylinder(d=kill_sw_dia + tol, h=panel_h + 2, center=true, $fn=50);
// Interior cavity (hollow for switch body)
translate([-kill_sw_dia/2 - 3, 5, 3])
cube([kill_sw_dia + 6, mount_d - 10, mount_h - 3]);
// Wire exit hole (bottom)
translate([0, mount_d/2, -1])
cylinder(d=10, h=5, $fn=25);
// Extrusion bolt holes (M5, through back bracket)
for (z = [7, 15]) {
translate([-20, mount_d + 5, z])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=15, center=true, $fn=25);
}
// Label recess ("EMERGENCY STOP" flat area for sticker)
translate([-25, 5, mount_h - 1])
cube([50, 15, 1.5]);
}
}
kill_switch_mount();

View File

@ -0,0 +1,53 @@
// ============================================
// SaltyLab LED Diffuser Ring
// Ø120×15mm Clear PETG 30% infill
// Wraps around frame, holds WS2812B strip
// Print in clear/natural PETG for diffusion
// ============================================
include <dimensions.scad>
ring_od = 120;
ring_id = 110; // Inner diameter (strip sits inside)
ring_h = 15;
strip_channel_w = led_strip_w + 1; // Strip channel
strip_channel_d = 3; // Depth for strip
module led_diffuser_ring() {
difference() {
// Outer ring
cylinder(d=ring_od, h=ring_h, $fn=80);
// Inner hollow
translate([0, 0, -1])
cylinder(d=ring_id, h=ring_h + 2, $fn=80);
// LED strip channel (groove on inner wall)
translate([0, 0, (ring_h - strip_channel_w)/2])
difference() {
cylinder(d=ring_id + 2, h=strip_channel_w, $fn=80);
cylinder(d=ring_id - strip_channel_d*2, h=strip_channel_w, $fn=80);
}
// Wire entry slot
translate([ring_od/2 - 5, -3, ring_h/2 - 3])
cube([10, 6, 6]);
// 2020 extrusion clearance (center)
translate([-extrusion_w/2 - 5, -extrusion_w/2 - 5, -1])
cube([extrusion_w + 10, extrusion_w + 10, ring_h + 2]);
}
// Mounting tabs (clip onto extrusion, 4x)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([extrusion_w/2 + 1, -5, 0])
difference() {
cube([3, 10, ring_h]);
translate([-1, 2, ring_h/2])
rotate([0, 90, 0])
cylinder(d=m3_clear, h=5, $fn=20);
}
}
}
led_diffuser_ring();

61
cad/lidar_standoff.scad Normal file
View File

@ -0,0 +1,61 @@
// ============================================
// SaltyLab LIDAR Standoff
// Ø80×80mm ASA
// Raises RPLIDAR above all other components
// for unobstructed 360° scan
// Connects sensor_tower_top to 2020 extrusion
// ============================================
include <dimensions.scad>
standoff_od = 80;
standoff_h = 80;
wall_t = 3;
module lidar_standoff() {
difference() {
union() {
// Main cylinder
cylinder(d=standoff_od, h=standoff_h, $fn=60);
// Bottom flange (bolts to extrusion bracket below)
cylinder(d=standoff_od + 10, h=4, $fn=60);
}
// Hollow interior
translate([0, 0, wall_t])
cylinder(d=standoff_od - wall_t*2, h=standoff_h, $fn=60);
// Cable routing hole (bottom)
translate([0, 0, -1])
cylinder(d=20, h=wall_t + 2, $fn=30);
// Ventilation / weight reduction slots (4x around circumference)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([0, standoff_od/2, standoff_h/2])
rotate([90, 0, 0])
hull() {
translate([0, -15, 0])
cylinder(d=10, h=wall_t + 2, center=true, $fn=25);
translate([0, 15, 0])
cylinder(d=10, h=wall_t + 2, center=true, $fn=25);
}
}
// Bottom flange bolt holes (M5, 4x for mounting)
for (angle = [45, 135, 225, 315]) {
rotate([0, 0, angle])
translate([standoff_od/2, 0, -1])
cylinder(d=m5_clear, h=6, $fn=25);
}
// Top mating holes (M3, align with sensor_tower_top)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([standoff_od/2 - wall_t - 3, 0, standoff_h - 8])
cylinder(d=m3_clear, h=10, $fn=25);
}
}
}
lidar_standoff();

View File

@ -0,0 +1,94 @@
// ============================================
// SaltyLab Motor Mount Plate
// 350×150×6mm PETG
// Mounts both 8" hub motors + 2020 extrusion spine
// ============================================
include <dimensions.scad>
plate_w = 350; // Width (axle to axle direction)
plate_d = 150; // Depth (front to back)
plate_h = 6; // Thickness
// Motor axle positions (centered, symmetric)
motor_spacing = 280; // Center-to-center axle distance
// Extrusion spine mount (centered, 2x M5 bolts)
spine_offset_y = 0; // Centered front-to-back
spine_bolt_spacing = 60; // Two bolts along spine
// Motor clamp dimensions
clamp_w = 30;
clamp_h = 25; // Height above plate for clamping axle
clamp_gap = motor_axle_dia + tol*2; // Slot for axle
clamp_bolt_offset = 10; // M5 clamp bolt offset from center
module motor_clamp() {
difference() {
// Clamp block
translate([-clamp_w/2, -clamp_w/2, 0])
cube([clamp_w, clamp_w, plate_h + clamp_h]);
// Axle hole (through, slightly oversized)
translate([0, 0, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=clamp_gap, h=clamp_w+2, center=true, $fn=40);
// Clamp slit (allows tightening)
translate([0, 0, plate_h + clamp_h/2 + 5])
cube([clamp_w+2, 1.5, clamp_h], center=true);
// Clamp bolt holes (M5, horizontal through clamp ears)
translate([0, clamp_bolt_offset, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=clamp_w+2, center=true, $fn=30);
translate([0, -clamp_bolt_offset, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=clamp_w+2, center=true, $fn=30);
}
}
module motor_mount_plate() {
difference() {
union() {
// Main plate
translate([-plate_w/2, -plate_d/2, 0])
cube([plate_w, plate_d, plate_h]);
// Left motor clamp
translate([-motor_spacing/2, 0, 0])
motor_clamp();
// Right motor clamp
translate([motor_spacing/2, 0, 0])
motor_clamp();
// Reinforcement ribs (bottom)
for (x = [-100, 0, 100]) {
translate([x - 2, -plate_d/2, 0])
cube([4, plate_d, plate_h]);
}
}
// Extrusion spine bolt holes (M5, 2x along center)
for (y = [-spine_bolt_spacing/2, spine_bolt_spacing/2]) {
translate([0, y, -1])
cylinder(d=m5_clear, h=plate_h+2, $fn=30);
// Counterbore for bolt head
translate([0, y, plate_h - 2.5])
cylinder(d=10, h=3, $fn=30);
}
// Weight reduction holes
for (x = [-70, 70]) {
for (y = [-40, 40]) {
translate([x, y, -1])
cylinder(d=25, h=plate_h+2, $fn=40);
}
}
// Corner rounding (chamfer edges)
// (simplified round in slicer or add minkowski)
}
}
motor_mount_plate();

View File

@ -0,0 +1,64 @@
// ============================================
// SaltyLab RealSense D435i Bracket
// 100×50×40mm PETG
// Adjustable tilt mount on 2020 extrusion
// ============================================
include <dimensions.scad>
bracket_w = 100;
bracket_d = 50;
bracket_h = 40;
// Camera cradle
cradle_w = rs_w + wall*2 + tol*2;
cradle_d = rs_d + wall + tol*2;
cradle_h = rs_h + 5;
module realsense_bracket() {
// Extrusion clamp base
difference() {
union() {
// Clamp block
translate([-20, -20, 0])
cube([40, 40, 15]);
// Tilt arm (vertical, supports camera above)
translate([-wall, -wall, 0])
cube([wall*2, wall*2, bracket_h]);
// Camera cradle at top
translate([-cradle_w/2, -cradle_d/2, bracket_h - 5]) {
difference() {
cube([cradle_w, cradle_d, cradle_h]);
// Camera pocket
translate([wall, -1, 3])
cube([rs_w + tol*2, rs_d + tol*2 + 1, rs_h + tol*2]);
}
}
// Tripod mount boss (1/4-20 bolt from bottom of cradle)
translate([0, 0, bracket_h - 5])
cylinder(d=15, h=3, $fn=30);
}
// 2020 extrusion channel
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 17]);
// Clamp bolt (M5, through side)
translate([-25, 0, 7.5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=50, $fn=30);
// Camera 1/4-20 bolt hole (from bottom of cradle)
translate([0, 0, bracket_h - 6])
cylinder(d=rs_mount_dia, h=10, $fn=30);
// Cable routing slot (back of cradle)
translate([-10, cradle_d/2 - wall - 1, bracket_h])
cube([20, wall + 2, cradle_h - 2]);
}
}
realsense_bracket();

58
cad/sensor_tower_top.scad Normal file
View File

@ -0,0 +1,58 @@
// ============================================
// SaltyLab Sensor Tower Top
// 120×120×10mm ASA
// Mounts RPLIDAR A1 on top of 2020 spine
// ============================================
include <dimensions.scad>
top_w = 120;
top_d = 120;
top_h = 10;
base_h = 4;
module sensor_tower_top() {
difference() {
union() {
// Circular plate (RPLIDAR needs 360° clearance)
cylinder(d=top_w, h=base_h, $fn=60);
// RPLIDAR standoffs (4x M2.5 on 67mm bolt circle)
for (i = [0:3]) {
angle = i * 90 + 45; // 45° offset
translate([cos(angle) * lidar_mount_circle/2,
sin(angle) * lidar_mount_circle/2, 0])
cylinder(d=6, h=top_h, $fn=25);
}
// 2020 extrusion socket (bottom center)
translate([-extrusion_w/2 - wall, -extrusion_w/2 - wall, -15])
cube([extrusion_w + wall*2, extrusion_w + wall*2, 15]);
}
// RPLIDAR M2.5 through-holes
for (i = [0:3]) {
angle = i * 90 + 45;
translate([cos(angle) * lidar_mount_circle/2,
sin(angle) * lidar_mount_circle/2, -1])
cylinder(d=lidar_hole_dia, h=top_h + 2, $fn=25);
}
// Center hole (RPLIDAR motor shaft clearance + cable routing)
translate([0, 0, -1])
cylinder(d=25, h=base_h + 2, $fn=40);
// 2020 extrusion socket (square hole)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -16])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 16]);
// Set screw holes for extrusion (M3, 2x perpendicular)
for (angle = [0, 90]) {
rotate([0, 0, angle])
translate([0, extrusion_w/2 + wall, -7.5])
rotate([90, 0, 0])
cylinder(d=m3_clear, h=wall + 5, $fn=25);
}
}
}
sensor_tower_top();

46
cad/tether_anchor.scad Normal file
View File

@ -0,0 +1,46 @@
// ============================================
// SaltyLab Tether Anchor Point
// 50×50×20mm PETG 100% infill
// For ceiling tether during balance testing
// Must be STRONG 100% infill mandatory
// ============================================
include <dimensions.scad>
anchor_w = 50;
anchor_d = 50;
anchor_h = 20;
ring_dia = 30; // Carabiner ring outer
ring_hole = 15; // Carabiner hook clearance
ring_h = 8;
module tether_anchor() {
difference() {
union() {
// Base (clamps to 2020 extrusion)
translate([-anchor_w/2, -anchor_d/2, 0])
cube([anchor_w, anchor_d, anchor_h - ring_h]);
// Tether ring (stands up from base)
translate([0, 0, anchor_h - ring_h])
cylinder(d=ring_dia, h=ring_h, $fn=50);
}
// Ring hole (for carabiner)
translate([0, 0, anchor_h - ring_h - 1])
cylinder(d=ring_hole, h=ring_h + 2, $fn=40);
// 2020 extrusion channel (through base)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, anchor_h - ring_h + 2]);
// Clamp bolt holes (M5, through sides)
for (angle = [0, 90]) {
rotate([0, 0, angle])
translate([0, anchor_d/2 + 1, (anchor_h - ring_h)/2])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=anchor_d + 2, $fn=25);
}
}
}
tether_anchor();

View File

@ -56,15 +56,24 @@
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.
### 7 FC mount (MAMBA F722S) <<<<<<< HEAD
1. Place silicone anti-vibration grommets onto nylon M3 standoffs. ### 7 MCU mount (ESP32 BALANCE + ESP32 IO)
2. Lower FC onto standoffs; secure with M3×6 BHCS. Snug only — do not over-torque.
3. Orient USB-C port toward front of robot for cable access.
### 8 Jetson Nano mount plate > ⚠️ **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.
3. Mount ESP32 IO board adjacent — exact placement TBD pending board dimensions.
4. Orient USB connectors toward front of robot for cable access.
### 8 Jetson Orin Nano Super mount plate
1. Press or thread M3 nylon standoffs (8mm) into plate holes. 1. Press or thread M3 nylon standoffs (8mm) into plate holes.
2. Bolt plate to deck: 4× M3×10 SHCS at deck corners. 2. Bolt plate to deck: 4× M3×10 SHCS at deck corners.
3. Set Jetson Nano B01 carrier onto plate standoffs; fasten M3×6 BHCS. 3. Set Jetson Orin Nano Super B01 carrier onto plate standoffs; fasten M3×6 BHCS.
### 9 Bumper brackets ### 9 Bumper brackets
1. Slide 22mm EMT conduit through saddle clamp openings. 1. Slide 22mm EMT conduit through saddle clamp openings.
@ -86,7 +95,8 @@
| Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm | | Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm |
| Motor fork slot width | 24 mm | +0.5 / 0 | | Motor fork slot width | 24 mm | +0.5 / 0 |
| Motor fork dropout depth | 60 mm | ±0.5 mm | | Motor fork dropout depth | 60 mm | ±0.5 mm |
| FC hole pattern | 30.5 × 30.5 mm | ±0.2 mm | | ESP32 BALANCE hole pattern | TBD — await spec from max | ±0.2 mm |
| ESP32 IO hole pattern | TBD — await spec from max | ±0.2 mm |
| Jetson hole pattern | 58 × 58 mm | ±0.2 mm | | Jetson hole pattern | 58 × 58 mm | ±0.2 mm |
| Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm | | Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm |

View File

@ -41,7 +41,11 @@ 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 |
| 6 | FC standoff M3×6mm nylon | 4 | Nylon | — | MAMBA F722S vibration isolation | <<<<<<< HEAD
| 6 | MCU standoff M3×6mm nylon | 4 | Nylon | — | ESP32 BALANCE / IO board isolation (dimensions TBD) |
=======
| 6 | FC standoff M3×6mm nylon | 4 | Nylon | — | ESP32-S3 BALANCE vibration isolation |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment | | 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment |
### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B ### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B
@ -70,7 +74,7 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 | | 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 |
| 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module | | 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module |
| 12 | FC mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height | | 12 | FC mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height |
| 13 | Jetson Nano mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern | | 13 | Jetson Orin Nano Super mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern |
| 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit | | 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit |
| 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 | | 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 |
@ -88,12 +92,23 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
## Electronics Mounts ## Electronics Mounts
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE (ESP32) is retired.
> Replaced by **ESP32 BALANCE** + **ESP32 IO**. Board dimensions and hole patterns TBD — await spec from max.
| # | Part | Qty | Spec | Notes | | # | Part | Qty | Spec | Notes |
|---|------|-----|------|-------| |---|------|-----|------|-------|
| 13 | STM32 MAMBA F722S FC | 1 | 36×36mm PCB, 30.5×30.5mm M3 mount | Oriented USB-C port toward front | <<<<<<< 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 |
| 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 Nano 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 |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs | | 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs |
--- ---
@ -144,8 +159,8 @@ Slide entire carousel up/down the stem with M6 collar bolts loosened. Tighten at
| 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts | | 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts |
| 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets | | 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets |
| 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) | | 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) |
| 29 | M3×10 SHCS | 12 | ISO 4762, SS | FC mount + miscellaneous | | 29 | M3×10 SHCS | 12 | ISO 4762, SS | ESP32 mount + miscellaneous |
| 30 | M3×6 BHCS | 4 | ISO 4762, SS | FC board bolts | | 30 | M3×6 BHCS | 4 | ISO 4762, SS | ESP32 board bolts (qty TBD pending board spec) |
| 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor | | 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor |
| 32 | Flat washer M5 | 32 | SS | | | 32 | Flat washer M5 | 32 | SS | |
| 33 | Flat washer M4 | 32 | SS | | | 33 | Flat washer M4 | 32 | SS | |

410
chassis/battery_holder.scad Normal file
View File

@ -0,0 +1,410 @@
// ============================================================
// battery_holder.scad 6S LiPo Battery Holder for 2020 T-Slot Chassis
// Issue: #588 Agent: sl-mechanical Date: 2026-03-14
// ============================================================
//
// Parametric bracket holding a 6S 5000 mAh LiPo pack on 2020 aluminium
// T-slot rails. Designed for low centre-of-gravity mounting: pack sits
// flat between the two chassis rails, as close to ground as clearance
// allows. Quick-release via captive Velcro straps battery swap in
// under 60 s without tools.
//
// Architecture:
// Tray flat floor + perimeter walls, battery sits inside
// Rail saddles two T-nut feet drop onto 2020 rails, thumbscrew locks
// Strap slots four pairs of slots for 25 mm Velcro strap loops
// XT60 window cut-out in rear wall for XT60 connector exit
// Balance port open channel in front wall for balance lead routing
// QR tab front-edge pull tab for one-handed battery extraction
//
// Part catalogue:
// Part 1 battery_tray() Main tray body (single-piece print)
// Part 2 rail_saddle() T-nut saddle foot (print x2 per tray)
// Part 3 strap_guide() 25 mm Velcro strap guide (print x4)
// Part 4 assembly_preview()
//
// Hardware BOM:
// 2× M3 × 16 mm SHCS + M3 hex nut T-nut rail clamp thumbscrews
// 2× 25 mm × 250 mm Velcro strap battery retention (hook + loop)
// 1× XT60 female connector (mounted on ESC/PDB harness)
// battery slides in from front, Velcro strap over top
//
// 6S LiPo target pack (verify with calipers packs vary by brand):
// BATT_L = 155 mm (length, X axis in tray)
// BATT_W = 48 mm (width, Y axis in tray)
// BATT_H = 52 mm (height, Z axis in tray)
// Clearance 1 mm each side added automatically (BATT_CLEAR)
//
// Mounting:
// Rail span : RAIL_SPAN distance between 2020 rail centrelines
// Default 80 mm; adjust to chassis rail spacing
// Saddle height: SADDLE_H total height of saddle (tray floor above rail)
// Keep low for CoG; default 8 mm
//
// RENDER options:
// "assembly" full assembly preview (default)
// "tray_stl" Part 1 battery tray
// "saddle_stl" Part 2 rail saddle (print x2)
// "strap_guide_stl" Part 3 strap guide (print x4)
//
// Export commands:
// openscad battery_holder.scad -D 'RENDER="tray_stl"' -o bh_tray.stl
// openscad battery_holder.scad -D 'RENDER="saddle_stl"' -o bh_saddle.stl
// openscad battery_holder.scad -D 'RENDER="strap_guide_stl"' -o bh_strap_guide.stl
//
// Print settings (all parts):
// Material : PETG
// Perimeters : 5 (tray, saddle), 3 (strap_guide)
// Infill : 40 % gyroid (tray floor, saddle), 20 % (strap_guide)
// Orientation:
// tray floor flat on bed (no supports needed)
// saddle flat face on bed (no supports)
// strap_guide flat face on bed (no supports)
// ============================================================
$fn = 64;
e = 0.01;
// Battery pack dimensions (verify with calipers)
BATT_L = 155.0; // pack length (X)
BATT_W = 48.0; // pack width (Y)
BATT_H = 52.0; // pack height (Z)
BATT_CLEAR = 1.0; // per-side fit clearance
// Tray geometry
TRAY_FLOOR_T = 4.0; // tray floor thickness
TRAY_WALL_T = 4.0; // tray perimeter wall thickness
TRAY_WALL_H = 20.0; // tray wall height (Z) cradles lower half of pack
TRAY_FILLET_R = 3.0; // inner corner radius
// Inner tray cavity (battery + clearance)
TRAY_INN_L = BATT_L + 2*BATT_CLEAR;
TRAY_INN_W = BATT_W + 2*BATT_CLEAR;
// Outer tray footprint
TRAY_OUT_L = TRAY_INN_L + 2*TRAY_WALL_T;
TRAY_OUT_W = TRAY_INN_W + 2*TRAY_WALL_T;
TRAY_TOTAL_H = TRAY_FLOOR_T + TRAY_WALL_H;
// Rail interface
RAIL_SPAN = 80.0; // distance between 2020 rail centrelines (Y)
RAIL_W = 20.0; // 2020 extrusion width
SLOT_NECK_H = 3.2; // T-slot neck height
SLOT_OPEN = 6.0; // T-slot opening width
SLOT_INN_W = 10.2; // T-slot inner width
SLOT_INN_H = 5.8; // T-slot inner height
// T-nut / saddle geometry
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_NUT_AF = 5.5; // M3 hex nut across-flats
TNUT_NUT_H = 2.4;
TNUT_BOLT_D = 3.3; // M3 clearance
SADDLE_W = 30.0; // saddle foot width (X, along rail)
SADDLE_T = 8.0; // saddle body thickness (Z, above rail top face)
SADDLE_PAD_T = 2.0; // rubber-pad recess depth (optional anti-slip)
// Velcro strap slots
STRAP_W = 26.0; // 25 mm strap + 1 mm clearance
STRAP_T = 4.0; // slot through-thickness (tray wall)
// Four slot pairs: one near each end of tray (X), one each side (Y)
// Slots run through side walls (Y direction) strap loops over battery top
// XT60 connector window (rear wall)
XT60_W = 14.0; // XT60 body width
XT60_H = 18.0; // XT60 body height (with cable exit)
XT60_OFFSET_Z = 4.0; // height above tray floor
// Balance lead port (front wall)
BAL_W = 40.0; // balance lead bundle width (6S = 7 wires)
BAL_H = 6.0; // balance lead channel height
BAL_OFFSET_Z = 8.0; // height above tray floor
// Quick-release pull tab (front edge)
QR_TAB_W = 30.0; // tab width
QR_TAB_H = 12.0; // tab height above front wall top
QR_TAB_T = 4.0; // tab thickness
QR_HOLE_D = 10.0; // finger-loop hole diameter
// Strap guide clip
GUIDE_OD = STRAP_W + 6.0;
GUIDE_T = 3.0;
GUIDE_BODY_H = 14.0;
// Fasteners
M3_D = 3.3;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tray_stl") battery_tray();
else if (RENDER == "saddle_stl") rail_saddle();
else if (RENDER == "strap_guide_stl") strap_guide();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
// Ghost 2020 rails (Y direction, RAIL_SPAN apart)
for (ry = [-RAIL_SPAN/2, RAIL_SPAN/2])
%color("Silver", 0.28)
translate([-TRAY_OUT_L/2 - 30, ry - RAIL_W/2, -SADDLE_T - TNUT_H])
cube([TRAY_OUT_L + 60, RAIL_W, RAIL_W]);
// Rail saddles (left and right)
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
color("DimGray", 0.85)
translate([0, sy, -SADDLE_T])
rail_saddle();
// Battery tray (sitting on saddles)
color("OliveDrab", 0.85)
battery_tray();
// Battery ghost
%color("SaddleBrown", 0.35)
translate([-BATT_L/2, -BATT_W/2, TRAY_FLOOR_T])
cube([BATT_L, BATT_W, BATT_H]);
// Strap guides (4×, two each end)
for (sx = [-TRAY_OUT_L/2 + STRAP_W/2 + TRAY_WALL_T + 8,
TRAY_OUT_L/2 - STRAP_W/2 - TRAY_WALL_T - 8])
for (sy = [-1, 1])
color("SteelBlue", 0.75)
translate([sx, sy*(TRAY_OUT_W/2), TRAY_TOTAL_H + 2])
rotate([sy > 0 ? 0 : 180, 0, 0])
strap_guide();
}
// ============================================================
// PART 1 BATTERY TRAY
// ============================================================
// Single-piece tray: flat floor, four perimeter walls, T-nut saddle
// attachment bosses on underside, Velcro strap slots through side walls,
// XT60 window in rear wall, balance lead channel in front wall, and
// quick-release pull tab on front edge.
//
// Battery inserts from the front (X end) front wall is lower than
// rear wall so the pack slides in and the rear wall stops it.
// Velcro straps loop over the top of the pack through the side slots.
//
// Coordinate convention:
// X: along battery length (X = front/plug-end, +X = rear/balance-end)
// Y: across battery width (centred, ±TRAY_OUT_W/2)
// Z: vertical (Z=0 = tray floor top face; Z = underside saddles)
//
// Print: floor flat on bed, PETG, 5 perims, 40% gyroid. No supports.
module battery_tray() {
// Short rear wall height (XT60 connector exits here full wall height)
// Front wall is lower to allow battery slide-in
front_wall_h = TRAY_WALL_H * 0.55; // 55% height battery slides over
difference() {
union() {
// Floor
translate([-TRAY_OUT_L/2, -TRAY_OUT_W/2, -TRAY_FLOOR_T])
cube([TRAY_OUT_L, TRAY_OUT_W, TRAY_FLOOR_T]);
// Rear wall (+X, full height)
translate([TRAY_INN_L/2, -TRAY_OUT_W/2, 0])
cube([TRAY_WALL_T, TRAY_OUT_W, TRAY_WALL_H]);
// Front wall (X, lowered for slide-in)
translate([-TRAY_INN_L/2 - TRAY_WALL_T, -TRAY_OUT_W/2, 0])
cube([TRAY_WALL_T, TRAY_OUT_W, front_wall_h]);
// Side walls (±Y)
for (sy = [-1, 1])
translate([-TRAY_OUT_L/2,
sy*(TRAY_INN_W/2 + (sy>0 ? 0 : -TRAY_WALL_T)),
0])
cube([TRAY_OUT_L,
TRAY_WALL_T,
TRAY_WALL_H]);
// Quick-release pull tab (front wall top edge)
translate([-TRAY_INN_L/2 - TRAY_WALL_T - e,
-QR_TAB_W/2, front_wall_h])
cube([QR_TAB_T, QR_TAB_W, QR_TAB_H]);
// Saddle attachment bosses (underside, one per rail)
// Bosses drop into saddle sockets; M3 bolt through floor
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
translate([-SADDLE_W/2, sy - SADDLE_W/2, -TRAY_FLOOR_T - SADDLE_T/2])
cube([SADDLE_W, SADDLE_W, SADDLE_T/2 + e]);
}
// Battery cavity (hollow interior)
translate([-TRAY_INN_L/2, -TRAY_INN_W/2, -e])
cube([TRAY_INN_L, TRAY_INN_W, TRAY_WALL_H + 2*e]);
// XT60 connector window (rear wall)
// Centred on rear wall, low position so cable exits cleanly
translate([TRAY_INN_L/2 - e, -XT60_W/2, XT60_OFFSET_Z])
cube([TRAY_WALL_T + 2*e, XT60_W, XT60_H]);
// Balance lead channel (front wall)
// Wide slot for 6S balance lead (7-pin JST-XH ribbon)
translate([-TRAY_INN_L/2 - TRAY_WALL_T - e,
-BAL_W/2, BAL_OFFSET_Z])
cube([TRAY_WALL_T + 2*e, BAL_W, BAL_H]);
// Velcro strap slots (side walls, 2 pairs)
// Pair A: near front end (X), Pair B: near rear end (+X)
// Each slot runs through the wall in Y direction
for (sx = [-TRAY_INN_L/2 + STRAP_W*0.5 + 10,
TRAY_INN_L/2 - STRAP_W*0.5 - 10])
for (sy = [-1, 1]) {
translate([sx - STRAP_W/2,
sy*(TRAY_INN_W/2) - (sy > 0 ? TRAY_WALL_T + e : -e),
TRAY_WALL_H * 0.35])
cube([STRAP_W, TRAY_WALL_T + 2*e, STRAP_T]);
}
// QR tab finger-loop hole
translate([-TRAY_INN_L/2 - TRAY_WALL_T/2,
0, front_wall_h + QR_TAB_H * 0.55])
rotate([0, 90, 0])
cylinder(d = QR_HOLE_D, h = QR_TAB_T + 2*e, center = true);
// Saddle bolt holes (M3 through floor into saddle boss)
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
translate([0, sy, -TRAY_FLOOR_T - e])
cylinder(d = M3_D, h = TRAY_FLOOR_T + 2*e);
// Floor lightening grid (non-structural area)
// 2D grid of pockets reduces weight without weakening battery support
for (gx = [-40, 0, 40])
for (gy = [-12, 12])
translate([gx, gy, -TRAY_FLOOR_T - e])
cylinder(d = 14, h = TRAY_FLOOR_T - 1.5 + e);
// Inner corner chamfers (battery slide-in guidance)
// 45° chamfers at bottom-front inner corners
translate([-TRAY_INN_L/2, -TRAY_INN_W/2 - e, -e])
rotate([0, 0, 45])
cube([4, 4, TRAY_WALL_H * 0.3 + e]);
translate([-TRAY_INN_L/2, TRAY_INN_W/2 + e, -e])
rotate([0, 0, -45])
cube([4, 4, TRAY_WALL_H * 0.3 + e]);
}
}
// ============================================================
// PART 2 RAIL SADDLE
// ============================================================
// T-nut foot that clamps to the top face of a 2020 T-slot rail.
// Battery tray boss drops into saddle socket; M3 bolt through tray
// floor and saddle body locks everything together.
// M3 thumbscrew through side of saddle body grips the rail T-groove
// (same thumbscrew interface as all other SaltyLab rail brackets).
//
// Saddle sits on top of rail (no T-nut tongue needed saddle clamps
// from the top using a T-nut inserted into the rail T-groove from the
// end). Low profile keeps battery CoG as low as possible.
//
// Print: flat base on bed, PETG, 5 perims, 50% gyroid.
module rail_saddle() {
sock_d = SADDLE_W - 4; // tray boss socket diameter
difference() {
union() {
// Main saddle body
translate([-SADDLE_W/2, -SADDLE_W/2, 0])
cube([SADDLE_W, SADDLE_W, SADDLE_T]);
// T-nut tongue (enters rail T-groove from above)
translate([-TNUT_W/2, -TNUT_L/2, -SLOT_NECK_H])
cube([TNUT_W, TNUT_L, SLOT_NECK_H + e]);
// T-nut inner body (locks in groove)
translate([-TNUT_W/2, -TNUT_L/2, -SLOT_NECK_H - (TNUT_H - SLOT_NECK_H)])
cube([TNUT_W, TNUT_L, TNUT_H - SLOT_NECK_H + e]);
}
// Rail channel clearance (bottom of saddle straddles rail)
// Saddle body has a channel that sits over the rail top face
translate([-RAIL_W/2 - e, -SADDLE_W/2 - e, -e])
cube([RAIL_W + 2*e, SADDLE_W + 2*e, 2.0]);
// M3 clamp bolt bore (through saddle body into T-nut)
translate([0, 0, -SLOT_NECK_H - TNUT_H - e])
cylinder(d = TNUT_BOLT_D, h = SADDLE_T + TNUT_H + 2*e);
// M3 hex nut pocket (top face of saddle for thumbscrew)
translate([0, 0, SADDLE_T - TNUT_NUT_H - 0.5])
cylinder(d = TNUT_NUT_AF / cos(30),
h = TNUT_NUT_H + 0.6, $fn = 6);
// Tray boss socket (top face of saddle, tray boss nests here)
// Cylindrical socket receives tray underside boss; M3 bolt centres
translate([0, 0, SADDLE_T - 3])
cylinder(d = sock_d + 0.4, h = 3 + e);
// M3 tray bolt bore (vertical, through saddle top)
translate([0, 0, SADDLE_T - 3 - e])
cylinder(d = M3_D, h = SADDLE_T + e);
// Anti-slip pad recess (bottom face, optional rubber adhesive)
translate([0, 0, -e])
cylinder(d = SADDLE_W - 8, h = SADDLE_PAD_T + e);
// Lightening pockets
for (lx = [-1, 1], ly = [-1, 1])
translate([lx*8, ly*8, -e])
cylinder(d = 5, h = SADDLE_T - 3 - 1 + e);
}
}
// ============================================================
// PART 3 STRAP GUIDE
// ============================================================
// Snap-on guide that sits on top of tray wall at each strap slot,
// directing the 25 mm Velcro strap from the side slot up and over
// the battery top. Four per tray, one at each slot exit.
// Curved lip prevents strap from cutting into PETG wall edge.
// Push-fit onto tray wall top; no fasteners required.
//
// Print: flat base on bed, PETG, 3 perims, 20% infill.
module strap_guide() {
strap_w_clr = STRAP_W + 0.5; // strap slot with clearance
lip_r = 3.0; // guide lip radius
difference() {
union() {
// Body (sits on tray wall top edge)
translate([-GUIDE_OD/2, 0, 0])
cube([GUIDE_OD, GUIDE_T, GUIDE_BODY_H]);
// Curved guide lip (top of body, strap bends around this)
translate([0, GUIDE_T/2, GUIDE_BODY_H])
rotate([0, 90, 0])
cylinder(r = lip_r, h = GUIDE_OD, center = true);
// Wall engagement tabs (snap over tray wall top)
for (sy = [0, -(TRAY_WALL_T + GUIDE_T)])
translate([-strap_w_clr/2 - 3, sy - GUIDE_T, 0])
cube([strap_w_clr + 6, GUIDE_T, GUIDE_BODY_H * 0.4]);
}
// Strap slot (through body)
translate([-strap_w_clr/2, -e, -e])
cube([strap_w_clr, GUIDE_T + 2*e, GUIDE_BODY_H + 2*e]);
// Wall clearance slot (body slides over tray wall)
translate([-strap_w_clr/2 - 3 - e,
-TRAY_WALL_T - GUIDE_T, -e])
cube([strap_w_clr + 6 + 2*e,
TRAY_WALL_T, GUIDE_BODY_H * 0.4 + 2*e]);
// Lightening pockets on side faces
for (lx = [-GUIDE_OD/4, GUIDE_OD/4])
translate([lx, GUIDE_T/2, GUIDE_BODY_H/2])
cube([6, GUIDE_T + 2*e, GUIDE_BODY_H * 0.5], center = true);
}
}

410
chassis/cable_tray.scad Normal file
View File

@ -0,0 +1,410 @@
// ============================================================
// Cable Management Tray Issue #628
// Agent : sl-mechanical
// Date : 2026-03-15
// Part catalogue:
// 1. tray_body under-plate tray with snap-in cable channels, Velcro
// tie-down slots every 40 mm, pass-through holes, label slots
// 2. tnut_bracket 2020 T-nut rail mount bracket (×2, slide under tray)
// 3. channel_clip snap-in divider clip separating power / signal / servo zones
// 4. cover_panel hinged snap-on lid (living-hinge PETG flexure strip)
// 5. cable_saddle individual cable saddle / strain-relief clip (×n)
//
// BOM:
// 4 × M5×10 BHCS + M5 T-nuts (tnut_bracket × 2 to rail)
// 4 × M3×8 SHCS (tnut_bracket to tray body)
// n × 100 mm Velcro tie-down strips (through 6×2 mm slots, every 40 mm)
//
// Cable channel layout (X axis, inside tray):
// Zone A Power (2S6S LiPo, XT60/XT30): 20 mm wide, 14 mm deep
// Zone B Signal (JST-SH, PWM, I2C, UART): 14 mm wide, 10 mm deep
// Zone C Servo (JST-PH, thick servo leads): 14 mm wide, 12 mm deep
// Divider walls: 2.5 mm thick between zones
//
// Print settings (PETG):
// tray_body / tnut_bracket / channel_clip : 5 perimeters, 40 % gyroid, no supports
// cover_panel : 3 perimeters, 20 % gyroid, no supports
// (living-hinge print flat, thin strip flexes)
// cable_saddle : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tray_body"' -o tray_body.stl cable_tray.scad
// openscad -D 'RENDER="tnut_bracket"' -o tnut_bracket.stl cable_tray.scad
// openscad -D 'RENDER="channel_clip"' -o channel_clip.stl cable_tray.scad
// openscad -D 'RENDER="cover_panel"' -o cover_panel.stl cable_tray.scad
// openscad -D 'RENDER="cable_saddle"' -o cable_saddle.stl cable_tray.scad
// openscad -D 'RENDER="assembly"' -o assembly.png cable_tray.scad
// ============================================================
RENDER = "assembly"; // tray_body | tnut_bracket | channel_clip | cover_panel | cable_saddle | assembly
$fn = 48;
EPS = 0.01;
// 2020 rail constants
RAIL_W = 20.0;
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
SLOT_NECK_H = 3.2;
M5_D = 5.2;
M5_HEAD_D = 9.5;
M5_HEAD_H = 4.0;
// Tray geometry
TRAY_L = 280.0; // length along rail (7 × 40 mm tie-down pitch)
TRAY_W = 60.0; // width across rail (covers standard 40 mm rail pair)
TRAY_WALL = 2.5; // side / floor wall thickness
TRAY_DEPTH = 18.0; // interior depth (tallest zone + wall)
// Cable channel zones (widths must sum to TRAY_W - 2*TRAY_WALL - 2*DIV_T)
DIV_T = 2.5; // divider wall thickness
ZONE_A_W = 20.0; // Power
ZONE_A_D = 14.0;
ZONE_B_W = 14.0; // Signal
ZONE_B_D = 10.0;
ZONE_C_W = 14.0; // Servo
ZONE_C_D = 12.0;
// Total inner width used: ZONE_A_W + ZONE_B_W + ZONE_C_W + 2*DIV_T = 55 mm < TRAY_W - 2*TRAY_WALL = 55 mm
// Tie-down slots (Velcro strips)
TIEDOWN_PITCH = 40.0;
TIEDOWN_W = 6.0; // slot width (fits 6 mm wide Velcro)
TIEDOWN_T = 2.2; // slot through-thickness (floor)
TIEDOWN_CNT = 7; // 7 positions along tray
// Pass-through holes in floor
PASSTHRU_D = 12.0; // circular grommet-compatible pass-through
PASSTHRU_CNT = 3; // one per zone, at tray mid-length
// Label slots (rear outer wall)
LABEL_W = 24.0;
LABEL_H = 8.0;
LABEL_T = 1.0; // depth from outer face
// Snap ledge for cover
SNAP_LEDGE_H = 2.5;
SNAP_LEDGE_D = 1.5;
// T-nut bracket
BKT_L = 60.0;
BKT_W = TRAY_W;
BKT_T = 6.0;
BOLT_PITCH = 40.0;
M3_D = 3.2;
M3_HEAD_D = 6.0;
M3_HEAD_H = 3.0;
M3_NUT_W = 5.5;
M3_NUT_H = 2.4;
// Cover panel
CVR_T = 1.8; // panel thickness
HINGE_T = 0.6; // living-hinge strip thickness (printed in PETG)
HINGE_W = 3.0; // hinge strip width (flexes easily)
SNAP_HOOK_H = 3.5; // snap hook height
SNAP_HOOK_T = 2.2;
// Cable saddle
SAD_W = 12.0;
SAD_H = 8.0;
SAD_T = 2.5;
SAD_BORE_D = 7.0; // cable bundle bore
SAD_CLIP_T = 1.6; // snap arm thickness
// Utilities
module chamfer_cube(size, ch=1.0) {
hull() {
translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch]) cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
cylinder(d=af/cos(30), h=depth, $fn=6);
}
// Part 1: tray_body
module tray_body() {
difference() {
// Outer shell
union() {
chamfer_cube([TRAY_L, TRAY_W, TRAY_DEPTH + TRAY_WALL], ch=1.5);
// Snap ledge along top of both long walls (for cover_panel)
for (y = [-SNAP_LEDGE_D, TRAY_W])
translate([0, y, TRAY_DEPTH])
cube([TRAY_L, TRAY_WALL + SNAP_LEDGE_D, SNAP_LEDGE_H]);
}
// Interior cavity
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, TRAY_W - 2*TRAY_WALL,
TRAY_DEPTH + EPS]);
// Zone dividers (subtract from solid to leave walls)
// Zone A (Power) inner floor cut full depth A
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL + (TRAY_DEPTH - ZONE_A_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_A_W, ZONE_A_D + EPS]);
// Zone B (Signal) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_B_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_B_W, ZONE_B_D + EPS]);
// Zone C (Servo) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_C_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_C_W, ZONE_C_D + EPS]);
// Velcro tie-down slots (floor, every 40 mm)
for (i = [0:TIEDOWN_CNT-1]) {
x = TRAY_WALL + 20 + i * TIEDOWN_PITCH - TIEDOWN_W/2;
// Zone A slot
translate([x, TRAY_WALL + 2, -EPS])
cube([TIEDOWN_W, ZONE_A_W - 4, TRAY_WALL + 2*EPS]);
// Zone B slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_B_W - 4, TRAY_WALL + 2*EPS]);
// Zone C slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_C_W - 4, TRAY_WALL + 2*EPS]);
}
// Pass-through holes in floor (centre of each zone at mid-length)
mid_x = TRAY_L / 2;
// Zone A
translate([mid_x, TRAY_WALL + ZONE_A_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone B
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone C
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Label slots on front wall (y = 0) one per zone
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
label_z = TRAY_WALL + 2;
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, -EPS, label_z])
cube([LABEL_W, LABEL_T + EPS, LABEL_H]);
// M3 bracket bolt holes in floor (4 corners)
for (x = [20, TRAY_L - 20])
for (y = [TRAY_W/4, 3*TRAY_W/4])
translate([x, y, -EPS])
cylinder(d=M3_D, h=TRAY_WALL + 2*EPS);
// Channel clip snap sockets (top of each divider, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
for (dy = [ZONE_A_W, ZONE_A_W + DIV_T + ZONE_B_W])
translate([cx - 3, TRAY_WALL + dy - 1, TRAY_DEPTH - 4])
cube([6, DIV_T + 2, 4 + EPS]);
}
}
// Divider walls (positive geometry)
// Wall between Zone A and Zone B
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_A_D]); // partial height lower in A zone
// Wall between Zone B and Zone C
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_B_D]);
}
// Part 2: tnut_bracket
module tnut_bracket() {
difference() {
chamfer_cube([BKT_L, BKT_W, BKT_T], ch=1.5);
// M5 T-nut holes (2 per bracket, on rail centreline)
for (x = [BKT_L/2 - BOLT_PITCH/2, BKT_L/2 + BOLT_PITCH/2]) {
translate([x, BKT_W/2, -EPS]) {
cylinder(d=M5_D, h=BKT_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, BKT_W/2 - TNUT_W/2, BKT_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// M3 tray-attachment holes (4 corners)
for (x = [10, BKT_L - 10])
for (y = [10, BKT_W - 10]) {
translate([x, y, -EPS])
cylinder(d=M3_D, h=BKT_T + 2*EPS);
// M3 hex nut captured pocket (from top)
translate([x, y, BKT_T - M3_NUT_H - 0.2])
hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.3);
}
// Weight relief
translate([15, 8, -EPS])
cube([BKT_L - 30, BKT_W - 16, BKT_T/2]);
}
}
// Part 3: channel_clip
// Snap-in clip that locks into divider-wall snap sockets;
// holds individual bundles in their zone and acts as colour-coded zone marker.
module channel_clip() {
clip_body_w = 6.0;
clip_body_h = DIV_T + 4.0;
clip_body_t = 8.0;
tab_h = 3.5;
tab_w = 2.5;
difference() {
union() {
// Body spanning divider
cube([clip_body_t, clip_body_w, clip_body_h]);
// Snap tabs (bottom, straddle divider)
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 1, s, -tab_h])
cube([2, tab_w, tab_h + 1]);
}
// Cable radius slot on each face
translate([-EPS, clip_body_w/2, clip_body_h * 0.6])
rotate([0, 90, 0])
cylinder(d=5.0, h=clip_body_t + 2*EPS);
// Snap tab undercut for flex
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 2, s - EPS, -tab_h + 1.5])
cube([4, tab_w + 2*EPS, 1.5]);
}
}
// Part 4: cover_panel
// Flat snap-on lid with living-hinge along one long edge.
// Print flat; PETG living hinge flexes ~90° to snap onto tray.
module cover_panel() {
total_w = TRAY_W + 2 * SNAP_HOOK_T;
difference() {
union() {
// Main panel
cube([TRAY_L, TRAY_W, CVR_T]);
// Living hinge strip along back edge (thin, flexes)
translate([0, TRAY_W - EPS, 0])
cube([TRAY_L, HINGE_W, HINGE_T]);
// Snap hooks along front edge (clips under tray snap ledge)
for (x = [20, TRAY_L/2 - 20, TRAY_L/2 + 20, TRAY_L - 20])
translate([x - SNAP_HOOK_T/2, -SNAP_HOOK_H + EPS, 0])
difference() {
cube([SNAP_HOOK_T, SNAP_HOOK_H, CVR_T + 1.5]);
// Hook nose chamfer
translate([-EPS, -EPS, CVR_T])
rotate([0, 0, 0])
cube([SNAP_HOOK_T + 2*EPS, 1.5, 1.5]);
}
}
// Ventilation slots (3 rows × 6 slots)
for (row = [0:2])
for (col = [0:5]) {
sx = 20 + col * 40 + row * 10;
sy = 10 + row * 12;
if (sx + 25 < TRAY_L && sy + 6 < TRAY_W)
translate([sx, sy, -EPS])
cube([25, 6, CVR_T + 2*EPS]);
}
// Zone label windows (align with tray label slots)
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, yc - LABEL_H/2, -EPS])
cube([LABEL_W, LABEL_H, CVR_T + 2*EPS]);
}
}
// Part 5: cable_saddle
// Snap-in cable saddle / strain-relief clip; press-fits onto tray top edge.
module cable_saddle() {
arm_gap = TRAY_WALL + 0.4; // fits over tray wall
arm_len = 8.0;
difference() {
union() {
// Body
chamfer_cube([SAD_W, SAD_T * 2 + arm_gap, SAD_H], ch=1.0);
// Cable retaining arch
translate([SAD_W/2, SAD_T + arm_gap/2, SAD_H])
scale([1, 0.6, 1])
difference() {
cylinder(d=SAD_BORE_D + SAD_CLIP_T * 2, h=SAD_T);
translate([0, 0, -EPS])
cylinder(d=SAD_BORE_D, h=SAD_T + 2*EPS);
translate([-SAD_BORE_D, 0, -EPS])
cube([SAD_BORE_D * 2, SAD_BORE_D, SAD_T + 2*EPS]);
}
}
// Slot for tray wall (negative)
translate([0, SAD_T, -EPS])
cube([SAD_W, arm_gap, arm_len + EPS]);
// M3 tie-down hole
translate([SAD_W/2, SAD_T + arm_gap/2, -EPS])
cylinder(d=M3_D, h=SAD_H + 2*EPS);
}
}
// Assembly
module assembly() {
// Tray body (open face up for visibility)
color("SteelBlue")
tray_body();
// Two T-nut brackets underneath at 1/4 and 3/4 length
for (bx = [TRAY_L/4 - BKT_L/2, 3*TRAY_L/4 - BKT_L/2])
color("DodgerBlue")
translate([bx, 0, -BKT_T])
tnut_bracket();
// Channel clips (3 per divider position, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
// Divider A/B
color("Tomato", 0.8)
translate([cx - 4, TRAY_WALL + ZONE_A_W - 2, TRAY_DEPTH - 3])
channel_clip();
// Divider B/C
color("Orange", 0.8)
translate([cx - 4,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W - 2,
TRAY_DEPTH - 3])
channel_clip();
}
// Cover panel (raised above tray to show interior)
color("LightSteelBlue", 0.5)
translate([0, 0, TRAY_DEPTH + SNAP_LEDGE_H + 4])
cover_panel();
// Cable saddles along front tray edge
for (x = [40, 120, 200])
color("SlateGray")
translate([x - SAD_W/2, -SAD_T * 2 - TRAY_WALL, 0])
cable_saddle();
}
// Dispatch
if (RENDER == "tray_body") tray_body();
else if (RENDER == "tnut_bracket") tnut_bracket();
else if (RENDER == "channel_clip") channel_clip();
else if (RENDER == "cover_panel") cover_panel();
else if (RENDER == "cable_saddle") cable_saddle();
else assembly();

265
chassis/canable_mount.scad Normal file
View File

@ -0,0 +1,265 @@
// ============================================================
// canable_mount.scad CANable 2.0 USB-CAN Adapter Cradle
// Issue #654 / sl-mechanical 2026-03-16
// ============================================================
// Snap-fit cradle for CANable 2.0 PCB (~60 × 18 × 10 mm).
// Attaches to 2020 aluminium T-slot rail via 2× M5 T-nuts.
//
// Port access:
// USB-C port X end wall cutout (connector protrudes through)
// CAN terminal X+ end wall cutout (CANH / CANL / GND wire exit)
// LED status window slot in Y+ side wall, PCB top-face LEDs visible
//
// Retention: snap-fit cantilever lips on both side walls (PETG flex).
// Cable strain relief: zip-tie boss pair on X+ shelf (CAN wires).
//
// VERIFY WITH CALIPERS BEFORE PRINTING:
// PCB_L, PCB_W board outline
// USBC_W, USBC_H USB-C shell at X edge
// TERM_W, TERM_H 3-pos terminal block at X+ edge
// LED_X_CTR, LED_WIN_W LED window position on Y+ wall
//
// Print settings (PETG):
// 3 perimeters, 40 % gyroid infill, no supports, 0.2 mm layer
// Print orientation: open face UP (as modelled)
//
// BOM:
// 2 × M5×10 BHCS + 2 × M5 slide-in T-nut (2020 rail)
//
// Export commands:
// openscad -D 'RENDER="mount"' -o canable_mount.stl canable_mount.scad
// openscad -D 'RENDER="assembly"' -o canable_assembly.png canable_mount.scad
// ============================================================
RENDER = "assembly"; // mount | assembly
$fn = 48;
EPS = 0.01;
// Verify before printing
// CANable 2.0 PCB
PCB_L = 60.0; // board length (X: USB-C end terminal end)
PCB_W = 18.0; // board width (Y)
PCB_T = 1.6; // board thickness
COMP_H = 8.5; // tallest component above board (USB-C shell 3.5 mm;
// terminal block 8.5 mm)
// USB-C connector (at X end face of PCB)
USBC_W = 9.5; // connector outer width
USBC_H = 3.8; // connector outer height above board surface
USBC_Z0 = 0.0; // connector bottom offset above board surface
// CAN screw-terminal block (at X+ end face, 3-pos 5.0 mm pitch)
TERM_W = 16.0; // terminal block span (3 × 5 mm + housing)
TERM_H = 9.0; // terminal block height above board surface
TERM_Z0 = 0.5; // terminal bottom offset above board surface
// Status LED window (LEDs near USB-C end on PCB top face)
// Rectangular slot cut in Y+ side wall LEDs visible from the side
LED_X_CTR = 11.0; // LED zone centre measured from PCB X edge
LED_WIN_W = 14.0; // window width (X)
LED_WIN_H = 5.5; // window height (Z) opens top portion of side wall
// Cradle geometry
WALL_T = 2.5; // side/end wall thickness
FLOOR_T = 4.0; // floor plate thickness (accommodates M5 BHCS head pocket)
CL_SIDE = 0.30; // Y clearance per side (total 0.6 mm play)
CL_END = 0.40; // X clearance per end
// Interior cavity
INN_W = PCB_W + 2*CL_SIDE; // Y span
INN_L = PCB_L + 2*CL_END; // X span
INN_H = PCB_T + COMP_H + 1.2; // Z height (board + tallest comp + margin)
// Outer body
OTR_W = INN_W + 2*WALL_T; // Y
OTR_L = INN_L + 2*WALL_T; // X
OTR_H = FLOOR_T + INN_H; // Z
// PCB reference origin within body (lower-left corner of board)
PCB_X0 = WALL_T + CL_END; // board X start inside body
PCB_Y0 = WALL_T + CL_SIDE; // board Y start inside body
PCB_Z0 = FLOOR_T; // board bottom sits on floor
// Snap-fit lips
// Cantilever ledge on inner face of each side wall, at PCB-top Z.
// Tapered (chamfered) entry guides PCB in from above.
SNAP_IN = 0.8; // how far inward ledge protrudes over PCB edge
SNAP_T = 1.2; // snap-arm thickness (thin for PETG flex)
SNAP_H = 4.0; // cantilever arm height (root at OTR_H, tip near PCB_Z0+PCB_T)
SNAP_L = 18.0; // arm length along X (centred on PCB, shorter = more flex)
// Snap on Y wall protrudes in +Y direction; Y+ wall protrudes in Y direction
// M5 T-nut mount (2020 rail)
M5_D = 5.3; // M5 bolt clearance bore
M5_HEAD_D = 9.5; // M5 BHCS head pocket diameter (from bottom face)
M5_HEAD_H = 3.0; // BHCS head pocket depth
M5_SPAC = 20.0; // bolt spacing along X (centred on cradle)
// Standard M5 slide-in T-nuts used no T-nut pocket moulded in.
// Cable strain relief
// Two zip-tie anchor bosses on a shelf inside the X+ end, straddling
// the CAN terminal wires.
SR_BOSS_OD = 7.0; // boss outer diameter
SR_BOSS_H = 5.5; // boss height above floor
SR_SLOT_W = 3.5; // zip-tie slot width
SR_SLOT_T = 2.2; // zip-tie slot through-height
// Boss Y positions (straddle terminal block)
SR_Y1 = WALL_T + INN_W * 0.25;
SR_Y2 = WALL_T + INN_W * 0.75;
SR_X = OTR_L - WALL_T - SR_BOSS_OD/2 - 2.5; // just inside X+ end wall
//
module canable_mount() {
difference() {
// Outer solid body
union() {
cube([OTR_L, OTR_W, OTR_H]);
// Snap cantilever arms on Y wall (protrude inward +Y)
// Arms hang down from top of Y wall inner face.
// Root is flush with inner face (Y = WALL_T); tip at PCB level.
translate([OTR_L/2 - SNAP_L/2, WALL_T - SNAP_T, OTR_H - SNAP_H])
cube([SNAP_L, SNAP_T, SNAP_H]);
// Snap cantilever arms on Y+ wall (protrude inward Y)
translate([OTR_L/2 - SNAP_L/2, OTR_W - WALL_T, OTR_H - SNAP_H])
cube([SNAP_L, SNAP_T, SNAP_H]);
// Cable strain relief bosses (X+ end, inside)
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy, 0])
cylinder(d=SR_BOSS_OD, h=SR_BOSS_H);
}
// Interior cavity
translate([WALL_T, WALL_T, FLOOR_T])
cube([INN_L, INN_W, INN_H + EPS]);
// USB-C cutout X end wall
// Centred on PCB width; opened from board surface upward
translate([-EPS,
PCB_Y0 + PCB_W/2 - (USBC_W + 1.5)/2,
PCB_Z0 + USBC_Z0 - 0.5])
cube([WALL_T + 2*EPS, USBC_W + 1.5, USBC_H + 2.5]);
// CAN terminal cutout X+ end wall
// Full terminal width + 2 mm margin for screwdriver access;
// height clears terminal block + wire bend radius
translate([OTR_L - WALL_T - EPS,
PCB_Y0 + PCB_W/2 - (TERM_W + 2.0)/2,
PCB_Z0 + TERM_Z0 - 0.5])
cube([WALL_T + 2*EPS, TERM_W + 2.0, TERM_H + 5.0]);
// LED status window Y+ side wall
// Rectangular slot; LEDs at top-face of PCB are visible through it
translate([PCB_X0 + LED_X_CTR - LED_WIN_W/2,
OTR_W - WALL_T - EPS,
OTR_H - LED_WIN_H])
cube([LED_WIN_W, WALL_T + 2*EPS, LED_WIN_H + EPS]);
// M5 BHCS head pockets (from bottom face of floor)
for (mx = [OTR_L/2 - M5_SPAC/2, OTR_L/2 + M5_SPAC/2])
translate([mx, OTR_W/2, -EPS]) {
// Clearance bore through full floor
cylinder(d=M5_D, h=FLOOR_T + 2*EPS);
// BHCS head pocket from bottom face
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
// Snap-arm ledge slot Y arm (hollow out to thin arm)
// Arm is SNAP_T thick; cut away material behind arm
translate([OTR_L/2 - SNAP_L/2 - EPS, EPS, OTR_H - SNAP_H])
cube([SNAP_L + 2*EPS, WALL_T - SNAP_T - EPS, SNAP_H + EPS]);
// Snap-arm ledge slot Y+ arm
translate([OTR_L/2 - SNAP_L/2 - EPS, OTR_W - WALL_T + SNAP_T, OTR_H - SNAP_H])
cube([SNAP_L + 2*EPS, WALL_T - SNAP_T - EPS, SNAP_H + EPS]);
// Snap-arm inward ledge notch (entry chamfer removed)
// Chamfer top of snap arm so PCB slides in easily
// Y arm: chamfer on upper-inner edge 45° wedge on +Y/+Z corner
translate([OTR_L/2 - SNAP_L/2 - EPS,
WALL_T - SNAP_T - EPS,
OTR_H - SNAP_IN])
rotate([0, 0, 0])
rotate([45, 0, 0])
cube([SNAP_L + 2*EPS, SNAP_IN * 1.5, SNAP_IN * 1.5]);
// Y+ arm: chamfer on upper-inner edge
translate([OTR_L/2 - SNAP_L/2 - EPS,
OTR_W - WALL_T + SNAP_T - SNAP_IN * 1.5 + EPS,
OTR_H - SNAP_IN])
rotate([45, 0, 0])
cube([SNAP_L + 2*EPS, SNAP_IN * 1.5, SNAP_IN * 1.5]);
// Snap ledge cutout on Y arm inner tip
// Creates inward nub: remove top portion of arm inner tip
// leaving bottom SNAP_IN height as the retaining ledge
translate([OTR_L/2 - SNAP_L/2 - EPS,
WALL_T - SNAP_T - EPS,
PCB_Z0 + PCB_T + SNAP_IN])
cube([SNAP_L + 2*EPS, SNAP_T + 2*EPS,
OTR_H - (PCB_Z0 + PCB_T + SNAP_IN) + EPS]);
// Snap ledge cutout on Y+ arm inner tip
translate([OTR_L/2 - SNAP_L/2 - EPS,
OTR_W - WALL_T - EPS,
PCB_Z0 + PCB_T + SNAP_IN])
cube([SNAP_L + 2*EPS, SNAP_T + 2*EPS,
OTR_H - (PCB_Z0 + PCB_T + SNAP_IN) + EPS]);
// Zip-tie slots through strain relief bosses
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy,
SR_BOSS_H/2 - SR_SLOT_T/2])
rotate([0, 90, 0])
cube([SR_SLOT_T, SR_SLOT_W,
SR_BOSS_OD + 2*EPS],
center=true);
// Weight relief pocket in floor (underside)
translate([WALL_T + 8, WALL_T + 3, -EPS])
cube([OTR_L - 2*WALL_T - 16, OTR_W - 2*WALL_T - 6,
FLOOR_T - 1.5 + EPS]);
}
}
// Assembly preview
if (RENDER == "assembly") {
color("DimGray", 0.93) canable_mount();
// Phantom PCB
color("MidnightBlue", 0.35)
translate([PCB_X0, PCB_Y0, PCB_Z0])
cube([PCB_L, PCB_W, PCB_T]);
// Phantom component block (top of PCB)
color("DarkSlateGray", 0.25)
translate([PCB_X0, PCB_Y0, PCB_Z0 + PCB_T])
cube([PCB_L, PCB_W, COMP_H]);
// USB-C port highlight
color("Gold", 0.8)
translate([-1,
PCB_Y0 + PCB_W/2 - USBC_W/2,
PCB_Z0 + USBC_Z0])
cube([WALL_T + 2, USBC_W, USBC_H]);
// Terminal block highlight
color("Tomato", 0.7)
translate([OTR_L - WALL_T - 1,
PCB_Y0 + PCB_W/2 - TERM_W/2,
PCB_Z0 + TERM_Z0])
cube([WALL_T + 2, TERM_W, TERM_H]);
// LED zone highlight
color("LimeGreen", 0.9)
translate([PCB_X0 + LED_X_CTR - LED_WIN_W/2,
OTR_W - WALL_T - 0.5,
OTR_H - LED_WIN_H + 1])
cube([LED_WIN_W, 1, LED_WIN_H - 2]);
} else {
canable_mount();
}

View File

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

View File

@ -0,0 +1,599 @@
// ============================================================
// gimbal_camera_mount.scad Pan/Tilt Gimbal Mount for RealSense D435i
// Issue: #552 Agent: sl-mechanical Date: 2026-03-14
// ============================================================
//
// Parametric gimbal bracket system mounting an Intel RealSense D435i
// (or similar box camera) on a 2-axis pan/tilt gimbal driven by
// ST3215 serial bus servos (25T spline, Feetech/Waveshare).
//
// Architecture:
// Pan axis base T-nut clamps to 2020 rail; pan servo rotates yoke
// Tilt axis tilt servo horn plate bolts to ST3215 horn; camera cradle
// rocks on tilt axis
// Camera D435i captured via 1/4-20 UNC hex nut in cradle floor
// Damping PETG flexure ribs on camera contact faces (or TPU pads)
// Wiring USB-C cable routed through channel in cradle arm
//
// Part catalogue:
// Part 1 tnut_rail_base() 2020 rail T-nut base + pan servo seat
// Part 2 pan_yoke() U-yoke connecting pan servo to tilt axis
// Part 3 tilt_horn_plate() Plate bolting to ST3215 tilt servo horn
// Part 4 camera_cradle() D435i cradle with 1/4-20 captured nut
// Part 5 vibe_pad() PETG flexure vibration-damping pad (×2)
// Part 6 assembly_preview() Full assembly preview
//
// Hardware BOM (per gimbal):
// 2× ST3215 serial bus servo (pan + tilt)
// 2× servo horn (25T spline, Ø36 mm, 4× M3 bolt holes on Ø24 mm BC)
// 2× M3 × 8 mm SHCS horn-to-plate bolts (×4 each horn = 8 total)
// 1× M3 × 16 mm SHCS + nut T-nut rail clamp thumbscrew
// 1× 1/4-20 UNC × 8 mm SHCS camera retention bolt (or existing tripod screw)
// 1× 1/4-20 UNC hex nut captured in cradle floor
// 4× M3 × 12 mm SHCS yoke-to-tilt-plate pivot axle bolts
// 2× M3 × 25 mm SHCS pan yoke attachment to servo body
// (optional) 2× vibe_pad printed in TPU 95A
//
// ST3215 servo interface (caliper-verified Feetech ST3215):
// Body footprint : 40.0 × 20.0 mm (W × D), 36.5 mm tall
// Shaft centre H : 28.5 mm from mounting face
// Shaft spline : 25T, centre Ø5.8 mm, D-cut
// Mount holes : 4× M3 on 32 × 10 mm rectangular pattern (18 mm offset)
// Horn bolt circle: Ø24 mm, 4× M3
// Horn OD : ~36 mm
//
// D435i camera interface (caliper-verified):
// Body : 90 × 25 × 25 mm (W × D × H)
// Tripod thread : 1/4-20 UNC, centred bottom face, 9 mm from front
// USB-C connector: right rear, 8 × 5 mm opening, 4 mm from edge
//
// Parametric camera size (override to adapt to other cameras):
// CAM_W, CAM_D, CAM_H body envelope
// CAM_MOUNT_X tripod hole X offset from camera centre
// CAM_MOUNT_Y tripod hole Y offset from front face
//
// Coordinate convention:
// Camera looks in +Y direction (forward)
// Pan axis is Z (vertical); tilt axis is X (lateral)
// Rail runs along Z; T-nut base at Z=0
// All parts at assembly origin; translate for assembly_preview
//
// RENDER options:
// "assembly" full assembly preview (default)
// "tnut_rail_base_stl" Part 1
// "pan_yoke_stl" Part 2
// "tilt_horn_plate_stl" Part 3
// "camera_cradle_stl" Part 4
// "vibe_pad_stl" Part 5
//
// Export commands:
// openscad gimbal_camera_mount.scad -D 'RENDER="tnut_rail_base_stl"' -o gcm_tnut_base.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="pan_yoke_stl"' -o gcm_pan_yoke.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="tilt_horn_plate_stl"' -o gcm_tilt_horn_plate.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="camera_cradle_stl"' -o gcm_camera_cradle.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="vibe_pad_stl"' -o gcm_vibe_pad.stl
// ============================================================
$fn = 64;
e = 0.01; // epsilon for boolean clearance
// Parametric camera envelope
// Override these for cameras other than D435i
CAM_W = 90.0; // camera body width (X)
CAM_D = 25.0; // camera body depth (Y)
CAM_H = 25.0; // camera body height (Z)
CAM_MOUNT_X = 0.0; // tripod hole X offset from camera body centre
CAM_MOUNT_Y = 9.0; // tripod hole from front face (Y) [D435i: 9 mm]
CAM_USBC_X = CAM_W/2 - 4; // USB-C connector X (right side)
CAM_USBC_Z = CAM_H/2; // USB-C connector Z (mid-height rear)
CAM_USBC_W = 9.0; // USB-C opening width (X)
CAM_USBC_H = 5.0; // USB-C opening height (Z)
// Rail geometry (matches sensor_rail.scad / sensor_rail_brackets.scad)
RAIL_W = 20.0;
SLOT_OPEN = 6.0;
SLOT_INNER_W = 10.2;
SLOT_INNER_H = 5.8;
SLOT_NECK_H = 3.2;
// T-nut geometry (matches sensor_rail_brackets.scad)
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_M3_NUT_AF = 5.5;
TNUT_M3_NUT_H = 2.5;
TNUT_BOLT_D = 3.3; // M3 clearance
// T-nut base plate geometry
BASE_W = 44.0; // wide enough for pan servo body (40 mm)
BASE_H = 40.0; // height along rail (Z)
BASE_T = SLOT_NECK_H + 2.0; // plate depth (Y), rail-face side
// ST3215 servo geometry
SERVO_W = 40.0; // servo body width (X)
SERVO_D = 20.0; // servo body depth (Y)
SERVO_H = 36.5; // servo body height (Z)
SERVO_SHAFT_Z = 28.5; // shaft centre height from mounting face
SERVO_HOLE_X = 16.0; // mount hole half-span X (32 mm span)
SERVO_HOLE_Y = 5.0; // mount hole half-span Y (10 mm span)
SERVO_M3_D = 3.3; // M3 clearance
// Servo horn geometry
HORN_OD = 36.0; // horn outer diameter
HORN_SPLINE_D = 5.9; // 25T spline bore clearance (5.8 + 0.1)
HORN_BC_D = 24.0; // bolt circle diameter (4× M3)
HORN_BOLT_D = 3.3; // M3 clearance through horn plate
HORN_PLATE_T = 5.0; // tilt horn plate thickness
// Yoke geometry
YOKE_WALL_T = 5.0; // yoke arm wall thickness
YOKE_ARM_H = 50.0; // yoke arm height (Z) clears servo body + camera
YOKE_INNER_W = CAM_W + 8.0; // yoke inner span (camera + pad clearance)
YOKE_BASE_T = 8.0; // yoke base plate thickness
// Tilt pivot
PIVOT_D = 4.3; // M4 pivot axle bore
PIVOT_BOSS_D = 10.0; // boss OD around pivot bore
PIVOT_BOSS_L = 6.0; // boss protrusion from yoke wall
// Camera cradle geometry
CRADLE_WALL_T = 4.0; // cradle side wall thickness
CRADLE_FLOOR_T = 5.0; // cradle floor thickness (holds 1/4-20 nut)
CRADLE_LIP_T = 3.0; // front retaining lip thickness
CRADLE_LIP_H = 8.0; // front lip height
CABLE_CH_W = 12.0; // USB-C cable channel width
CABLE_CH_H = 8.0; // USB-C cable channel height
// 1/4-20 UNC tripod thread
QTR20_D = 6.6; // 1/4-20 clearance bore
QTR20_NUT_AF = 11.1; // 1/4-20 hex nut across-flats (standard)
QTR20_NUT_H = 5.5; // 1/4-20 hex nut height
// Vibration-damping pad geometry
PAD_W = CAM_W - 2*CRADLE_WALL_T - 2;
PAD_H = CAM_H + 4;
PAD_T = 2.5; // pad body thickness
RIB_H = 1.5; // flexure rib height
RIB_W = 1.2; // rib width
RIB_PITCH = 5.0; // rib pitch
// Fastener sizes
M3_D = 3.3;
M4_D = 4.3;
M3_NUT_AF = 5.5;
M3_NUT_H = 2.4;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tnut_rail_base_stl") tnut_rail_base();
else if (RENDER == "pan_yoke_stl") pan_yoke();
else if (RENDER == "tilt_horn_plate_stl") tilt_horn_plate();
else if (RENDER == "camera_cradle_stl") camera_cradle();
else if (RENDER == "vibe_pad_stl") vibe_pad();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
asm_rail_z = 0;
// Rail section ghost (200 mm)
%color("Silver", 0.25)
translate([-RAIL_W/2, -RAIL_W/2, asm_rail_z])
cube([RAIL_W, RAIL_W, 200]);
// T-nut rail base
color("OliveDrab", 0.85)
translate([0, 0, asm_rail_z + 80])
tnut_rail_base();
// Pan servo ghost (sitting in base seat)
%color("DimGray", 0.4)
translate([-SERVO_W/2, BASE_T, asm_rail_z + 80 + (BASE_H - SERVO_H)/2])
cube([SERVO_W, SERVO_D, SERVO_H]);
// Pan yoke rising from servo shaft
color("SteelBlue", 0.85)
translate([0, BASE_T + SERVO_D, asm_rail_z + 80 + BASE_H/2])
pan_yoke();
// Tilt horn plate (tilt axis left yoke wall)
color("DarkOrange", 0.85)
translate([-YOKE_INNER_W/2 - YOKE_WALL_T - HORN_PLATE_T,
BASE_T + SERVO_D + YOKE_BASE_T,
asm_rail_z + 80 + BASE_H/2 + YOKE_ARM_H/2])
rotate([0, 90, 0])
tilt_horn_plate();
// Camera cradle (centred in yoke)
color("DarkSlateGray", 0.85)
translate([0, BASE_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T,
asm_rail_z + 80 + BASE_H/2 + YOKE_ARM_H/2 - CAM_H/2])
camera_cradle();
// D435i ghost
%color("Black", 0.4)
translate([-CAM_W/2,
BASE_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T + PAD_T,
asm_rail_z + 80 + Base_H_mid() - CAM_H/2])
cube([CAM_W, CAM_D, CAM_H]);
// Vibe pads (front + rear camera face)
color("DimGray", 0.80) {
translate([-CAM_W/2 + CRADLE_WALL_T + 1,
Base_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T,
asm_rail_z + 80 + Base_H_mid() - PAD_H/2])
rotate([90, 0, 0])
vibe_pad();
}
}
// helper (avoids recomputing same expression)
function Base_T() = BASE_T;
function Base_H_mid() = BASE_H/2 + YOKE_ARM_H/2;
// ============================================================
// PART 1 T-NUT RAIL BASE (pan servo seat + rail clamp)
// ============================================================
// Mounts to 2020 rail via standard T-nut tongue.
// Front face (+Y side) provides flat seat for pan ST3215 servo body.
// Servo body recessed 1 mm into seat for positive lateral registration.
// Pan servo shaft axis = Z (vertical) pan rotation about Z.
//
// Print: PETG, 5 perims, 50 % gyroid. Orient face-plate down (flat).
module tnut_rail_base() {
difference() {
union() {
// Face plate (against rail outer face, -Y side)
translate([-BASE_W/2, -BASE_T, 0])
cube([BASE_W, BASE_T, BASE_H]);
// T-nut neck (enters rail slot, +Y side of face plate)
translate([-TNUT_W/2, 0, (BASE_H - TNUT_L)/2])
cube([TNUT_W, SLOT_NECK_H + e, TNUT_L]);
// T-nut inner body (wider, locks inside T-groove)
translate([-TNUT_W/2, SLOT_NECK_H - e, (BASE_H - TNUT_L)/2])
cube([TNUT_W, TNUT_H - SLOT_NECK_H + e, TNUT_L]);
// Pan servo seat boss (front face, +Y side)
// Proud pad that servo body sits on; 1 mm registration recess
translate([-BASE_W/2, -BASE_T, 0])
cube([BASE_W, BASE_T + 6, BASE_H]);
}
// Rail clamp bolt bore (M3 through face plate)
translate([0, -BASE_T - e, BASE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_BOLT_D, h = BASE_T + TNUT_H + 2*e);
// M3 hex nut pocket (inside T-nut body)
translate([0, SLOT_NECK_H + 0.3, BASE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_M3_NUT_AF / cos(30),
h = TNUT_M3_NUT_H + 0.3, $fn = 6);
// Servo body recess (1 mm registration pocket in seat face)
translate([-SERVO_W/2 - 0.3, -BASE_T + 6 - 1.0,
(BASE_H - SERVO_H)/2 - 0.3])
cube([SERVO_W + 0.6, 1.2, SERVO_H + 0.6]);
// Pan servo mount holes (4× M3 in rectangular pattern)
for (sx = [-SERVO_HOLE_X, SERVO_HOLE_X])
for (sy = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([sx, -BASE_T + 6 + e, BASE_H/2 + sy])
rotate([90, 0, 0])
cylinder(d = SERVO_M3_D, h = BASE_T + 2*e);
// Pan servo shaft bore (passes shaft through base if needed)
// Centre of shaft at Z = BASE_H/2, no bore needed (shaft exits top)
// Lightening pockets
translate([0, -BASE_T/2 + 3, BASE_H/2])
cube([BASE_W - 14, BASE_T - 4, BASE_H - 14], center = true);
}
}
// ============================================================
// PART 2 PAN YOKE
// ============================================================
// U-shaped yoke that attaches to pan servo horn (below) and carries
// the tilt axis (above). Two vertical arms straddle the camera cradle.
// Tilt servo sits on top of one arm; tilt pivot boss on the other.
//
// Yoke base bolts to pan servo horn (4× M3 on HORN_BC_D bolt circle).
// Pan servo horn spline bore passes through yoke base centre.
// Tilt axis: M4 pivot axle through boss on each arm (X-axis rotation).
//
// Print: upright (yoke in final orientation), PETG, 5 perims, 40% gyroid.
module pan_yoke() {
arm_z_total = YOKE_ARM_H + YOKE_BASE_T;
inner_w = YOKE_INNER_W;
difference() {
union() {
// Yoke base plate (bolts to pan servo horn)
translate([-inner_w/2 - YOKE_WALL_T, 0, 0])
cube([inner_w + 2*YOKE_WALL_T, YOKE_BASE_T, YOKE_BASE_T]);
// Left arm
translate([-inner_w/2 - YOKE_WALL_T, 0, 0])
cube([YOKE_WALL_T, YOKE_BASE_T, arm_z_total]);
// Right arm (tilt servo side)
translate([inner_w/2, 0, 0])
cube([YOKE_WALL_T, YOKE_BASE_T, arm_z_total]);
// Tilt pivot bosses (both arms, X-axis)
// Left pivot boss (plain pivot M4 bolt)
translate([-inner_w/2 - YOKE_WALL_T - PIVOT_BOSS_L,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_BOSS_D, h = PIVOT_BOSS_L + YOKE_WALL_T);
// Right pivot boss (tilt servo horn seat)
translate([inner_w/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_BOSS_D + 4, h = PIVOT_BOSS_L + YOKE_WALL_T);
// Tilt servo body seat on right arm top
translate([inner_w/2, 0, arm_z_total - SERVO_H - 4])
cube([YOKE_WALL_T + SERVO_D + 2, YOKE_BASE_T, SERVO_H + 4]);
}
// Pan horn spline bore (centre of yoke base)
translate([0, YOKE_BASE_T/2, YOKE_BASE_T/2])
rotate([90, 0, 0])
cylinder(d = HORN_SPLINE_D, h = YOKE_BASE_T + 2*e,
center = true);
// Pan horn bolt holes (4× M3 on HORN_BC_D)
for (a = [45, 135, 225, 315])
translate([HORN_BC_D/2 * cos(a),
YOKE_BASE_T/2,
HORN_BC_D/2 * sin(a) + YOKE_BASE_T/2])
rotate([90, 0, 0])
cylinder(d = HORN_BOLT_D, h = YOKE_BASE_T + 2*e,
center = true);
// Left tilt pivot bore (M4 clearance)
translate([-inner_w/2 - YOKE_WALL_T - PIVOT_BOSS_L - e,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_D, h = PIVOT_BOSS_L + YOKE_WALL_T + 2*e);
// Right tilt pivot bore (larger tilt horn plate seats here)
translate([inner_w/2 - e,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = HORN_SPLINE_D,
h = PIVOT_BOSS_L + YOKE_WALL_T + 2*e);
// Tilt servo mount holes in right arm seat
for (sz = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([inner_w/2 + YOKE_WALL_T + SERVO_D/2,
YOKE_BASE_T/2,
arm_z_total - SERVO_H/2 + sz])
rotate([90, 0, 0])
cylinder(d = SERVO_M3_D, h = YOKE_BASE_T + 2*e,
center = true);
// M3 nut pockets (tilt servo mount, rear of arm seat)
for (sz = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([inner_w/2 + YOKE_WALL_T + SERVO_D/2,
YOKE_BASE_T - M3_NUT_H - 0.5,
arm_z_total - SERVO_H/2 + sz])
rotate([90, 0, 0])
cylinder(d = M3_NUT_AF / cos(30), h = M3_NUT_H + 0.5,
$fn = 6);
// Lightening slots in yoke arms
translate([-inner_w/2 - YOKE_WALL_T/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2 - 10])
cube([YOKE_WALL_T - 2, YOKE_BASE_T - 2, YOKE_ARM_H - 24],
center = true);
translate([inner_w/2 + YOKE_WALL_T/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2 - 10])
cube([YOKE_WALL_T - 2, YOKE_BASE_T - 2, YOKE_ARM_H - 30],
center = true);
}
}
// ============================================================
// PART 3 TILT HORN PLATE
// ============================================================
// Disc plate bolting to tilt ST3215 servo horn on the right yoke arm.
// Servo horn spline centres into disc bore (captured, no free rotation).
// Camera cradle attaches to opposite face via 2× M3 bolts.
//
// Tilt range: ±45° limited by yoke arm geometry.
// Plate thickness HORN_PLATE_T provides stiffness for cantilevered cradle.
//
// Print: flat (disc face down), PETG, 5 perims, 50 % infill.
module tilt_horn_plate() {
plate_od = HORN_OD + 8; // plate OD (4 mm rim outside horn BC)
difference() {
union() {
// Main disc
cylinder(d = plate_od, h = HORN_PLATE_T);
// Cradle attachment arm (extends to camera cradle)
// Rectangular boss on top of disc toward camera
translate([-CAM_W/2, HORN_PLATE_T - e, -CAM_H/2])
cube([CAM_W, HORN_PLATE_T + 4, CAM_H]);
}
// Servo horn spline bore (centre)
translate([0, 0, -e])
cylinder(d = HORN_SPLINE_D, h = HORN_PLATE_T + 2*e);
// Horn bolt holes (4× M3 on HORN_BC_D)
for (a = [45, 135, 225, 315])
translate([HORN_BC_D/2 * cos(a),
HORN_BC_D/2 * sin(a), -e])
cylinder(d = HORN_BOLT_D, h = HORN_PLATE_T + 2*e);
// Pivot axle bore (M4, coaxial with horn centre)
translate([0, 0, -e])
cylinder(d = PIVOT_D, h = HORN_PLATE_T + 2*e);
// Cradle attachment bolts (2× M3 in arm boss)
for (cz = [-CAM_H/2 + 6, CAM_H/2 - 6])
translate([0, HORN_PLATE_T + 2, cz])
rotate([90, 0, 0])
cylinder(d = M3_D, h = HORN_PLATE_T + 6 + 2*e);
// M3 hex nut pockets (rear of disc face)
for (cz = [-CAM_H/2 + 6, CAM_H/2 - 6])
translate([0, M3_NUT_H + 0.5, cz])
rotate([90, 0, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.5, $fn = 6);
// Weight-relief arcs (between horn bolt holes)
for (a = [0, 90, 180, 270])
translate([(plate_od/2 - 5) * cos(a),
(plate_od/2 - 5) * sin(a), -e])
cylinder(d = 6, h = HORN_PLATE_T + 2*e);
}
}
// ============================================================
// PART 4 CAMERA CRADLE
// ============================================================
// Open-front U-cradle holding D435i via captured 1/4-20 hex nut.
// Front lip retains camera from sliding forward (+Y).
// Vibration-damping pads seat in recessed pockets on inner faces.
// USB-C cable routing channel exits cradle right rear wall.
//
// 1/4-20 captured nut in cradle floor tighten with standard
// tripod screw or M61/4-20 adapter from camera bottom.
//
// Print: cradle-floor-down (flat), PETG, 5 perims, 40 % gyroid.
// No supports needed (overhangs < 45°).
module camera_cradle() {
outer_w = CAM_W + 2*CRADLE_WALL_T;
outer_h = CAM_H + CRADLE_FLOOR_T;
difference() {
union() {
// Cradle body
translate([-outer_w/2, 0, 0])
cube([outer_w, CAM_D + CRADLE_WALL_T, outer_h]);
// Front retaining lip
translate([-outer_w/2, CAM_D + CRADLE_WALL_T - CRADLE_LIP_T, 0])
cube([outer_w, CRADLE_LIP_T, CRADLE_LIP_H]);
// Cable channel boss (right rear, exits +X side)
translate([CAM_W/2 + CRADLE_WALL_T - e,
0,
CRADLE_FLOOR_T + CAM_H/2 - CABLE_CH_H/2])
cube([CABLE_CH_W + CRADLE_WALL_T, CAM_D * 0.6, CABLE_CH_H]);
// Tilt horn attachment tabs (left + right, bolt to horn plate)
for (sx = [-outer_w/2 - 4, outer_w/2])
translate([sx, CAM_D/2, CRADLE_FLOOR_T + CAM_H/2 - 6])
cube([4, 12, 12]);
}
// Camera pocket (hollow interior)
translate([-CAM_W/2, 0, CRADLE_FLOOR_T])
cube([CAM_W, CAM_D + CRADLE_WALL_T + e, CAM_H + e]);
// 1/4-20 UNC clearance bore (camera tripod thread, bottom)
translate([CAM_MOUNT_X, CAM_MOUNT_Y, -e])
cylinder(d = QTR20_D, h = CRADLE_FLOOR_T + 2*e);
// 1/4-20 hex nut pocket (captured in cradle floor)
translate([CAM_MOUNT_X, CAM_MOUNT_Y, CRADLE_FLOOR_T - QTR20_NUT_H - 0.5])
cylinder(d = QTR20_NUT_AF / cos(30),
h = QTR20_NUT_H + 0.6, $fn = 6);
// USB-C cable channel (exit through right rear wall)
translate([CAM_W/2 - e,
0,
CRADLE_FLOOR_T + CAM_H/2 - CABLE_CH_H/2])
cube([CABLE_CH_W + CRADLE_WALL_T + 2*e,
CAM_D * 0.6 + e, CABLE_CH_H]);
// Vibe pad recesses on inner camera-contact faces
// Rear wall recess (camera front face +Y side of rear wall)
translate([-CAM_W/2 + CRADLE_WALL_T, CRADLE_WALL_T, CRADLE_FLOOR_T])
cube([CAM_W, PAD_T, CAM_H]);
// Tilt horn bolt holes in attachment tabs
for (sx = [-outer_w/2 - 4 - e, outer_w/2 - e])
translate([sx, CAM_D/2 + 6, CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_D, h = 6 + 2*e);
// M3 nut pockets in attachment tabs
translate([outer_w/2 + 4 - M3_NUT_H - 0.4,
CAM_D/2 + 6,
CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.4, $fn = 6);
translate([-outer_w/2 - 4 - e,
CAM_D/2 + 6,
CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.4, $fn = 6);
// Lightening pockets in cradle walls
for (face_x = [-CAM_W/2 - CRADLE_WALL_T - e, CAM_W/2 - e])
translate([face_x, CAM_D * 0.2, CRADLE_FLOOR_T + 3])
cube([CRADLE_WALL_T + 2*e, CAM_D * 0.55, CAM_H - 6]);
}
}
// ============================================================
// PART 5 VIBRATION-DAMPING PAD
// ============================================================
// Flat pad with transverse PETG flexure ribs pressing against camera body.
// Rib geometry (thin fins ~1.5 mm tall) deflects under camera vibration,
// attenuating high-frequency input from motor/drive-train.
// For superior damping: print in TPU 95A (no infill changes needed).
// Pads seat in recessed pockets in camera cradle inner wall.
// Optional M2 bolt-through at corners or adhesive-back foam tape.
//
// Print: pad-back-face-down, PETG or TPU 95A, 3 perims, 20 % infill.
module vibe_pad() {
rib_count = floor((PAD_W - RIB_W) / RIB_PITCH);
union() {
// Base plate
translate([-PAD_W/2, -PAD_T, -PAD_H/2])
cube([PAD_W, PAD_T, PAD_H]);
// Flexure ribs (parallel to Z, spaced RIB_PITCH apart)
for (i = [0 : rib_count - 1]) {
rx = -PAD_W/2 + RIB_PITCH/2 + i * RIB_PITCH + RIB_W/2;
if (rx <= PAD_W/2 - RIB_W/2)
translate([rx, 0, 0])
cube([RIB_W, RIB_H, PAD_H - 6], center = true);
}
// Corner nubs (M2 bolt-through retention, optional)
for (px = [-PAD_W/2 + 5, PAD_W/2 - 5])
for (pz = [-PAD_H/2 + 5, PAD_H/2 - 5])
translate([px, -PAD_T/2, pz])
difference() {
cylinder(d = 5, h = PAD_T, center = true);
cylinder(d = 2.4, h = PAD_T + 2*e, center = true);
}
}
}

View File

@ -104,7 +104,11 @@ 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 |
| FC (MAMBA F722S) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK | <<<<<<< HEAD
| 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 | | ESC × 2 | Al pad → lid | 100 °C Tj | Target ≤ 60 °C |
| D435i | Passive; housing vent gap on rear cap | 45 °C surface | — | | D435i | Passive; housing vent gap on rear cap | 45 °C surface | — |

View File

@ -0,0 +1,386 @@
// ============================================================
// Jetson Orin Nano Carrier Board Mount Issue #612
// Agent : sl-mechanical
// Date : 2026-03-15
// Part catalogue:
// 1. tnut_base 2020 T-slot rail interface plate, M5 T-nut captive pockets
// 2. standoff_post M2.5 captive-nut standoff post (×4), 10 mm airflow gap
// 3. side_brace lateral stiffening brace with port-access cutouts (×2)
// 4. duct_shroud optional top heatsink duct / fan-exhaust channel
// 5. cable_clip snap-on cable management clip for brace edge
//
// BOM:
// 4 × M5×10 BHCS + M5 T-nuts (tnut_base to rail, 2 per rail)
// 4 × M2.5×20 SHCS (board to standoff posts)
// 4 × M2.5 hex nuts (captured in standoff posts)
// 4 × M3×8 SHCS + washers (side_brace to tnut_base)
// 2 × M3×16 SHCS (duct_shroud to side_brace tops)
//
// Jetson Orin Nano carrier board (Seeed reComputer / official dev kit):
// Board dims : 100 × 80 mm
// Mounting hole pattern : 86 × 58 mm (centre-to-centre), M2.5, Ø3.5 pad
// PCB thickness: 1.6 mm
// Connector side: -Y (USB-A, USB-C, HDMI, DP, GbE, SD on one long edge)
// Fan header & PWM header: +X short edge
// M.2 / NVMe: bottom face
//
// Print settings (PETG):
// tnut_base / standoff_post / side_brace / duct_shroud : 5 perimeters, 40 % gyroid, no supports
// cable_clip : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tnut_base"' -o tnut_base.stl jetson_orin_mount.scad
// openscad -D 'RENDER="standoff_post"' -o standoff_post.stl jetson_orin_mount.scad
// openscad -D 'RENDER="side_brace"' -o side_brace.stl jetson_orin_mount.scad
// openscad -D 'RENDER="duct_shroud"' -o duct_shroud.stl jetson_orin_mount.scad
// openscad -D 'RENDER="cable_clip"' -o cable_clip.stl jetson_orin_mount.scad
// openscad -D 'RENDER="assembly"' -o assembly.png jetson_orin_mount.scad
// ============================================================
// Render selector
RENDER = "assembly"; // tnut_base | standoff_post | side_brace | duct_shroud | cable_clip | assembly
// Global constants
$fn = 64;
EPS = 0.01;
// 2020 rail
RAIL_W = 20.0;
SLOT_NECK_H = 3.2;
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
M5_D = 5.2;
M5_HEAD_D = 9.5;
M5_HEAD_H = 4.0;
// Jetson Orin Nano carrier board
BOARD_L = 100.0; // board X
BOARD_W = 80.0; // board Y
BOARD_T = 1.6; // PCB thickness
MH_SX = 86.0; // mounting hole span X (centre-to-centre)
MH_SY = 58.0; // mounting hole span Y
M25_D = 2.7; // M2.5 clearance bore
M25_NUT_W = 5.0; // M2.5 hex nut across-flats
M25_NUT_H = 2.0; // M2.5 hex nut height
M25_HEAD_D = 5.0; // M2.5 SHCS head diameter
M25_HEAD_H = 2.5;
// Base plate
BASE_L = 120.0; // length along X (covers board + overhang for braces)
BASE_W = 50.0; // width along Y (rail mount footprint)
BASE_T = 6.0; // plate thickness
BOLT_PITCH = 40.0; // M5 rail bolt pitch (per rail, 2 rails at Y=0 & Y=BASE_W)
M3_D = 3.2;
M3_HEAD_D = 6.0;
M3_HEAD_H = 3.0;
// Standoff posts
POST_H = 12.0; // airflow gap + PCB seating (>= 10 mm clearance spec)
POST_OD = 8.0; // outer diameter
POST_BASE_D = 11.0; // flange diameter
POST_BASE_H = 3.0; // flange height
NUT_TRAP_H = M25_NUT_H + 0.3;
NUT_TRAP_W = M25_NUT_W + 0.4;
// Side braces
BRACE_T = 5.0; // brace thickness (X)
BRACE_H = POST_H + POST_BASE_H + BOARD_T + 4.0; // full height
BRACE_W = BASE_W; // same width as base
// Port-access cutouts (connector side -Y)
USB_CUT_W = 60.0; // wide cutout for USB-A stack + HDMI + DP
USB_CUT_H = 22.0;
GBE_CUT_W = 20.0; // GbE jack
GBE_CUT_H = 18.0;
// Duct shroud
DUCT_T = 3.0; // wall thickness
DUCT_FLANGE = 6.0; // side tab width for M3 attachment
FAN_W = 40.0; // standard 40 mm blower clearance cutout
FAN_H = 10.0; // duct outlet height
// Cable clip
CLIP_OD = 12.0;
CLIP_ID = 7.0;
CLIP_GAP = 7.5;
CLIP_W = 10.0;
SNAP_T = 1.8;
// Utilities
module chamfer_cube(size, ch=1.0) {
hull() {
translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch]) cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
cylinder(d=af/cos(30), h=depth, $fn=6);
}
// Part 1: tnut_base
module tnut_base() {
difference() {
union() {
chamfer_cube([BASE_L, BASE_W, BASE_T], ch=1.5);
// Raised mounting bosses for M3 brace attachment (4 corners)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, BASE_T])
cylinder(d=10, h=2.5);
}
// T-nut pockets and M5 bolts front rail (y = BASE_W/4)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2]) {
translate([x, BASE_W/4, -EPS]) {
cylinder(d=M5_D, h=BASE_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, BASE_W/4 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// T-nut pockets and M5 bolts rear rail (y = 3*BASE_W/4)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2]) {
translate([x, 3*BASE_W/4, -EPS]) {
cylinder(d=M5_D, h=BASE_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, 3*BASE_W/4 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// M3 boss bolt holes (corner braces)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, -EPS])
cylinder(d=M3_D, h=BASE_T + 2.5 + 2*EPS);
// M3 boss counterbores (head from bottom)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, -EPS])
cylinder(d=M3_HEAD_D, h=M3_HEAD_H + EPS);
// Standoff post seating holes (board hole pattern, centred on plate)
bx0 = BASE_L/2 - MH_SX/2;
by0 = BASE_W/2 - MH_SY/2;
for (dx = [0, MH_SX])
for (dy = [0, MH_SY])
translate([bx0+dx, by0+dy, -EPS])
cylinder(d=POST_BASE_D + 0.4, h=BASE_T + 2*EPS);
// Weight relief grid (2 pockets)
translate([20, 12, -EPS]) cube([30, BASE_W-24, BASE_T/2]);
translate([BASE_L-50, 12, -EPS]) cube([30, BASE_W-24, BASE_T/2]);
// Cable pass-through slot
translate([BASE_L/2 - 8, BASE_W/2 - 3, -EPS])
cube([16, 6, BASE_T + 2*EPS]);
}
}
// Part 2: standoff_post
module standoff_post() {
difference() {
union() {
// Flange
cylinder(d=POST_BASE_D, h=POST_BASE_H);
// Post body
translate([0, 0, POST_BASE_H])
cylinder(d=POST_OD, h=POST_H);
}
// M2.5 through bore
translate([0, 0, -EPS])
cylinder(d=M25_D, h=POST_BASE_H + POST_H + 2*EPS);
// Captured hex nut trap (from top)
translate([0, 0, POST_BASE_H + POST_H - NUT_TRAP_H])
hex_pocket(NUT_TRAP_W, NUT_TRAP_H + EPS);
// Anti-rotation flat on nut pocket
translate([-M25_NUT_W/2 - 0.2, -POST_OD/2 - EPS,
POST_BASE_H + POST_H - NUT_TRAP_H])
cube([M25_NUT_W + 0.4, 2.0, NUT_TRAP_H + EPS]);
}
}
// Part 3: side_brace
// Printed as +X face. Mirror for -X side.
module side_brace() {
difference() {
union() {
chamfer_cube([BRACE_T, BRACE_W, BRACE_H], ch=1.0);
// Top lip to retain board edge
translate([0, 0, BRACE_H])
cube([BRACE_T + 8.0, BRACE_W, 2.5]);
}
// M3 bolt holes at base (attach to tnut_base bosses)
for (y = [8, BRACE_W-8])
translate([-EPS, y, 4])
rotate([0, 90, 0])
cylinder(d=M3_D, h=BRACE_T + 2*EPS);
// M3 counterbore from outer face
for (y = [8, BRACE_W-8])
translate([-EPS, y, 4])
rotate([0, 90, 0])
cylinder(d=M3_HEAD_D, h=M3_HEAD_H + EPS);
// Port-access cutout USB/HDMI/DP cluster (centred on brace face)
translate([-EPS, BRACE_W/2 - USB_CUT_W/2, POST_BASE_H + 2.0])
cube([BRACE_T + 2*EPS, USB_CUT_W, USB_CUT_H]);
// GbE cutout (offset toward +Y)
translate([-EPS, BRACE_W/2 + USB_CUT_W/2 - GBE_CUT_W - 2, POST_BASE_H + 2.0])
cube([BRACE_T + 2*EPS, GBE_CUT_W, GBE_CUT_H]);
// M3 duct attachment holes (top edge)
for (y = [BRACE_W/4, 3*BRACE_W/4])
translate([BRACE_T/2, y, BRACE_H - 2])
cylinder(d=M3_D, h=10);
// Ventilation slots (3 tall slots for airflow)
for (i = [0:2])
translate([-EPS,
(BRACE_W - 3*8 - 2*4) / 2 + i*(8+4),
POST_BASE_H + USB_CUT_H + 6])
cube([BRACE_T + 2*EPS, 8, BRACE_H - POST_BASE_H - USB_CUT_H - 10]);
}
}
// Part 4: duct_shroud
// Top cap that channels fan exhaust away from board; optional print.
module duct_shroud() {
duct_l = BASE_L - 2*BRACE_T - 1.0; // span between inner brace faces
duct_w = BRACE_W;
difference() {
union() {
// Top plate
cube([duct_l, duct_w, DUCT_T]);
// Front wall (fan inlet side)
translate([0, 0, -FAN_H])
cube([DUCT_T, duct_w, FAN_H + DUCT_T]);
// Rear wall (exhaust side open centre)
translate([duct_l - DUCT_T, 0, -FAN_H])
cube([DUCT_T, duct_w, FAN_H + DUCT_T]);
// Side flanges for M3 attachment
translate([-DUCT_FLANGE, 0, -FAN_H])
cube([DUCT_FLANGE, duct_w, FAN_H + DUCT_T]);
translate([duct_l, 0, -FAN_H])
cube([DUCT_FLANGE, duct_w, FAN_H + DUCT_T]);
}
// Fan cutout on top plate (centred)
translate([duct_l/2 - FAN_W/2, duct_w/2 - FAN_W/2, -EPS])
cube([FAN_W, FAN_W, DUCT_T + 2*EPS]);
// Fan screw holes (40 mm fan, Ø3.2 at 32 mm BC)
for (dx = [-16, 16])
for (dy = [-16, 16])
translate([duct_l/2 + dx, duct_w/2 + dy, -EPS])
cylinder(d=M3_D, h=DUCT_T + 2*EPS);
// Exhaust slot on rear wall (full width minus corners)
translate([duct_l - DUCT_T - EPS, 4, -FAN_H + 2])
cube([DUCT_T + 2*EPS, duct_w - 8, FAN_H - 2]);
// M3 flange attachment holes
for (y = [duct_w/4, 3*duct_w/4]) {
translate([-DUCT_FLANGE - EPS, y, -FAN_H/2])
rotate([0, 90, 0])
cylinder(d=M3_D, h=DUCT_FLANGE + 2*EPS);
translate([duct_l + DUCT_T - EPS, y, -FAN_H/2])
rotate([0, 90, 0])
cylinder(d=M3_D, h=DUCT_FLANGE + 2*EPS);
}
}
}
// Part 5: cable_clip
module cable_clip() {
difference() {
union() {
// Snap-wrap body
difference() {
cylinder(d=CLIP_OD + 2*SNAP_T, h=CLIP_W);
translate([0, 0, -EPS])
cylinder(d=CLIP_ID, h=CLIP_W + 2*EPS);
// Front gap
translate([-CLIP_GAP/2, 0, -EPS])
cube([CLIP_GAP, CLIP_OD, CLIP_W + 2*EPS]);
}
// Mounting tab for brace edge
translate([CLIP_OD/2 + SNAP_T - EPS, -SNAP_T, 0])
cube([8, SNAP_T*2, CLIP_W]);
}
// Tab screw hole
translate([CLIP_OD/2 + SNAP_T + 4, 0, CLIP_W/2])
rotate([90, 0, 0])
cylinder(d=M3_D, h=SNAP_T*2 + 2*EPS, center=true);
}
}
// Assembly
module assembly() {
// Base plate
color("SteelBlue")
tnut_base();
// Standoff posts (board hole pattern)
bx0 = BASE_L/2 - MH_SX/2;
by0 = BASE_W/2 - MH_SY/2;
for (dx = [0, MH_SX])
for (dy = [0, MH_SY])
color("DodgerBlue")
translate([bx0+dx, by0+dy, BASE_T])
standoff_post();
// Side braces (left and right)
color("CornflowerBlue")
translate([0, 0, BASE_T])
side_brace();
color("CornflowerBlue")
translate([BASE_L, BRACE_W, BASE_T])
mirror([1, 0, 0])
mirror([0, 1, 0])
side_brace();
// Board silhouette (translucent, for clearance visualisation)
color("ForestGreen", 0.25)
translate([BASE_L/2 - BOARD_L/2, BASE_W/2 - BOARD_W/2,
BASE_T + POST_BASE_H + POST_H])
cube([BOARD_L, BOARD_W, BOARD_T]);
// Duct shroud (above board)
color("LightSteelBlue", 0.7)
translate([BRACE_T + 0.5, 0,
BASE_T + POST_BASE_H + POST_H + BOARD_T + 2.0])
duct_shroud();
// Cable clips (on brace edge, 2×)
for (y = [BRACE_W/3, 2*BRACE_W/3])
color("SlateGray")
translate([BASE_L + 2, y, BASE_T + BRACE_H/2 - CLIP_W/2])
rotate([0, 90, 0])
cable_clip();
}
// Dispatch
if (RENDER == "tnut_base") tnut_base();
else if (RENDER == "standoff_post") standoff_post();
else if (RENDER == "side_brace") side_brace();
else if (RENDER == "duct_shroud") duct_shroud();
else if (RENDER == "cable_clip") cable_clip();
else assembly();

View File

@ -0,0 +1,504 @@
// ============================================================
// phone_mount_bracket.scad Spring-Loaded Phone Mount for T-Slot Rail
// Issue: #535 Agent: sl-mechanical Date: 2026-03-07
// ============================================================
//
// Parametric spring-loaded phone mount that clamps to the 2020 aluminium
// T-slot sensor rail. Adjustable phone width 6085 mm. Quick-release
// cam lever for tool-free phone swap. Vibration-damping flexure ribs
// on grip pads absorb motor/terrain vibration (PETG compliance).
//
// Design overview:
// - Fixed jaw + sliding jaw on a 40 mm guide rail (M4 rod)
// - Coil spring (Ø8 × 30 mm) compressed between jaw and end-stop
// spring pre-load keeps phone clamped at any width in range
// - Cam lever (printed PETG) rotates 90° to release / lock spring
// - Anti-vibration flexure ribs on both grip pad faces
// - Landscape or portrait orientation: bracket rotates on T-nut base
//
// Parts (STL exports):
// Part 1 tnut_base() Rail attachment base (universal)
// Part 2 fixed_jaw() Fixed bottom jaw + guide rail bosses
// Part 3 sliding_jaw() Spring-loaded sliding jaw
// Part 4 cam_lever() Quick-release cam lever
// Part 5 grip_pad() Flexure grip pad (print ×2, TPU optional)
// Part 6 assembly_preview() Full assembly
//
// Hardware BOM (per mount):
// 1× M4 × 60 mm SHCS guide rod + spring bolt
// 1× M4 hex nut end-stop on sliding jaw
// 1× Ø8 × 30 mm coil spring ~0.5 N/mm rate (spring clamping)
// 2× M3 × 16 mm SHCS T-nut base thumbscrew + arm bolts
// 1× M3 hex nut thumbscrew nut in T-nut
// 4× M2 × 8 mm SHCS grip pad retention bolts (optional)
//
// Dimensions:
// Phone width range : PHONE_W_MINPHONE_W_MAX (6085 mm) parametric
// Phone thickness : up to PHONE_THICK_MAX (12 mm) open-front jaw
// Phone height held : GRIP_SPAN (22 mm each jaw) portrait/landscape
// Overall bracket H : ~110 mm W: ~90 mm D: ~55 mm
//
// Print settings:
// Material : PETG (tnut_base, fixed_jaw, sliding_jaw, cam_lever)
// TPU 95A optional for grip_pad (or PETG for rigidity)
// Perimeters: 5 (structural parts), 3 (grip_pad)
// Infill : 40 % gyroid (jaws), 20 % (grip_pad)
// Supports : none needed (designed for FDM orientation)
// Layer ht : 0.2 mm
//
// Export commands:
// openscad phone_mount_bracket.scad -D 'RENDER="tnut_base_stl"' -o pm_tnut_base.stl
// openscad phone_mount_bracket.scad -D 'RENDER="fixed_jaw_stl"' -o pm_fixed_jaw.stl
// openscad phone_mount_bracket.scad -D 'RENDER="sliding_jaw_stl"' -o pm_sliding_jaw.stl
// openscad phone_mount_bracket.scad -D 'RENDER="cam_lever_stl"' -o pm_cam_lever.stl
// openscad phone_mount_bracket.scad -D 'RENDER="grip_pad_stl"' -o pm_grip_pad.stl
// ============================================================
$fn = 64;
e = 0.01; // epsilon for boolean clearance
// Phone parameters (adjust to target device)
PHONE_W_MIN = 60.0; // narrowest phone width supported (mm)
PHONE_W_MAX = 85.0; // widest phone width supported (mm)
PHONE_THICK_MAX = 12.0; // max phone body thickness incl. case (mm)
// Rail geometry (must match sensor_rail.scad)
RAIL_W = 20.0;
SLOT_OPEN = 6.0;
SLOT_INNER_W = 10.2;
SLOT_INNER_H = 5.8;
SLOT_NECK_H = 3.2;
// T-nut constants
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_M3_NUT_AF = 5.5;
TNUT_M3_NUT_H = 2.5;
TNUT_BOLT_D = 3.3; // M3 clearance
// Base plate geometry
BASE_FACE_W = 30.0;
BASE_FACE_H = 25.0;
BASE_FACE_T = SLOT_NECK_H + 1.5;
// Jaw geometry
JAW_BODY_W = 88.0; // jaw outer width (> PHONE_W_MAX for rim)
JAW_BODY_H = 28.0; // jaw height (Z) phone grip span
JAW_BODY_T = 14.0; // jaw depth (Y) phone cradled this deep
JAW_WALL_T = 4.0; // jaw side wall thickness
JAW_LIP_T = 3.0; // front retaining lip thickness
JAW_LIP_H = 5.0; // front lip height (retains phone)
PHONE_POCKET_D = PHONE_THICK_MAX + 0.5; // pocket depth for phone
// Guide rod / spring system
GUIDE_ROD_D = 4.3; // M4 clearance bore in sliding jaw
GUIDE_BOSS_D = 10.0; // boss OD around guide bore
GUIDE_BOSS_T = 6.0; // boss length
SPRING_OD = 8.5; // coil spring OD pocket (spring is Ø8)
SPRING_L = 32.0; // spring pocket length (spring compressed ~22 mm)
SPRING_SEAT_T = 3.0; // spring seat wall at end-stop boss
JAW_TRAVEL = PHONE_W_MAX - PHONE_W_MIN + 4.0; // max jaw travel (mm)
ARM_SPAN = PHONE_W_MAX + 2 * JAW_WALL_T + 8; // fixed jaw total width
// Cam lever geometry
CAM_R_MIN = 5.0; // cam small radius (engaged / clamped)
CAM_R_MAX = 9.0; // cam large radius (released, spring compressed)
CAM_THICK = 8.0; // cam disc thickness
CAM_HANDLE_L = 45.0; // lever arm length
CAM_HANDLE_W = 8.0; // lever handle width
CAM_HANDLE_T = 5.0; // lever handle thickness
CAM_BORE_D = 4.3; // M4 pivot bore
CAM_DETENT_D = 3.0; // detent ball pocket (3 mm bearing)
// Grip pad geometry (vibration dampening flexure ribs)
PAD_W = JAW_BODY_W - 2*JAW_WALL_T - 2; // pad width
PAD_H = JAW_BODY_H - 2; // pad height
PAD_T = 2.5; // pad body thickness
RIB_H = 1.5; // flexure rib height above pad face
RIB_W = 1.2; // rib width
RIB_PITCH = 5.0; // rib pitch (centre-to-centre)
RIB_COUNT = floor(PAD_W / RIB_PITCH) - 1;
// Arm geometry (base to jaw body)
ARM_REACH = 38.0; // distance from rail face to jaw centreline (+Y)
ARM_T = 4.0; // arm thickness
ARM_H = BASE_FACE_H;
// Fasteners
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;
M4_NUT_AF = 7.0; // M4 hex nut across-flats
M4_NUT_H = 3.2; // M4 hex nut height
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tnut_base_stl") tnut_base();
else if (RENDER == "fixed_jaw_stl") fixed_jaw();
else if (RENDER == "sliding_jaw_stl") sliding_jaw();
else if (RENDER == "cam_lever_stl") cam_lever();
else if (RENDER == "grip_pad_stl") grip_pad();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
// Ghost rail section (20 × 20 × 200)
%color("Silver", 0.30)
linear_extrude(200)
square([RAIL_W, RAIL_W], center = true);
// T-nut base at Z=80 on rail
color("OliveDrab", 0.85)
translate([0, 0, 80])
tnut_base();
// Fixed jaw assembly (centred, extending +Y from base)
color("DarkSlateGray", 0.85)
translate([0, SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
fixed_jaw();
// Sliding jaw shown at mid-travel (phone ~72 mm wide)
color("SteelBlue", 0.85)
translate([PHONE_W_MIN + (PHONE_W_MAX - PHONE_W_MIN)/2,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
sliding_jaw();
// Grip pads on both jaws
color("DimGray", 0.85) {
translate([0, SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
translate([JAW_WALL_T, JAW_BODY_T, JAW_BODY_H/2])
rotate([90, 0, 0])
grip_pad();
translate([PHONE_W_MIN + (PHONE_W_MAX - PHONE_W_MIN)/2,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
translate([-JAW_WALL_T - PAD_T, JAW_BODY_T, JAW_BODY_H/2])
rotate([90, 0, 180])
grip_pad();
}
// Cam lever shown in locked (clamped) position
color("OrangeRed", 0.85)
translate([ARM_SPAN/2 + 6,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH + GUIDE_BOSS_D/2,
80 + JAW_BODY_H/2])
rotate([0, 0, 0])
cam_lever();
}
// ============================================================
// PART 1 T-NUT BASE
// ============================================================
// Standard 2020 T-slot rail attachment base.
// Identical interface to sensor_rail_brackets.scad universal_tnut_base().
// Arm extends in +Y; rail clamp bolt in -Y face.
//
// Print flat (face plate down), PETG, 5 perims, 60 % infill.
module tnut_base() {
difference() {
union() {
// Face plate (flush against rail outer face)
translate([-BASE_FACE_W/2, -BASE_FACE_T, 0])
cube([BASE_FACE_W, BASE_FACE_T, BASE_FACE_H]);
// T-nut neck (enters rail slot)
translate([-TNUT_W/2, 0, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, SLOT_NECK_H + e, TNUT_L]);
// T-nut body (wider, inside T-groove)
translate([-TNUT_W/2, SLOT_NECK_H - e, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, TNUT_H - SLOT_NECK_H + e, TNUT_L]);
// Arm stub (face plate jaw)
translate([-BASE_FACE_W/2, -BASE_FACE_T, 0])
cube([BASE_FACE_W, BASE_FACE_T + ARM_REACH, ARM_T]);
}
// M3 rail clamp bolt bore (centre of T-nut, through face plate)
translate([0, -BASE_FACE_T - e, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_BOLT_D, h = BASE_FACE_T + TNUT_H + 2*e);
// M3 hex nut pocket (inside T-nut body)
translate([0, SLOT_NECK_H + 0.3, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_M3_NUT_AF / cos(30),
h = TNUT_M3_NUT_H + 0.3,
$fn = 6);
// 2× M3 bolt holes for arm-to-jaw bolting
for (bx = [-10, 10])
translate([bx, ARM_REACH - BASE_FACE_T - e, ARM_T/2])
rotate([-90, 0, 0])
cylinder(d = M3_D, h = 8 + 2*e);
// Lightening slot in arm
translate([0, -BASE_FACE_T/2 + ARM_REACH/2, ARM_T/2])
cube([BASE_FACE_W - 12, ARM_REACH - 16, ARM_T + 2*e],
center = true);
}
}
// ============================================================
// PART 2 FIXED JAW
// ============================================================
// Fixed lower jaw of the clamping system. Phone sits in the pocket
// formed by the fixed jaw (bottom) and sliding jaw (top).
// Two guide bosses on the right wall carry the M4 guide rod + spring.
// The cam lever pivot boss is on the outer right face.
//
// Coordinate origin: centre-bottom of jaw body.
// Phone entry face: +Y (open front), phone pocket opens toward +Y.
// Fixed jaw left edge is at X = -JAW_BODY_W/2.
//
// Print jaw-pocket-face down, PETG, 5 perims, 40 % infill.
module fixed_jaw() {
difference() {
union() {
// Main jaw body
translate([-JAW_BODY_W/2, -JAW_BODY_T/2, 0])
cube([JAW_BODY_W, JAW_BODY_T, JAW_BODY_H]);
// Front retaining lip (keeps phone from falling forward)
translate([-JAW_BODY_W/2, JAW_BODY_T/2 - JAW_LIP_T, 0])
cube([JAW_BODY_W, JAW_LIP_T, JAW_LIP_H]);
// Guide boss right (outer, carries spring + end-stop)
translate([JAW_BODY_W/2, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_BOSS_D, h = GUIDE_BOSS_T);
// Cam lever pivot boss (right face, above guide boss)
translate([JAW_BODY_W/2, 0, JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_THICK + 4, h = 6);
// Arm attachment bosses (left side, connect to tnut_base)
for (bx = [-10, 10])
translate([bx, -JAW_BODY_T/2 - 8, ARM_T/2])
cylinder(d = 8, h = 8);
}
// Phone pocket (open-top U channel centred in jaw)
// Pocket opens toward +Y (front), phone drops in from above.
translate([0, -JAW_BODY_T/2 - e,
JAW_LIP_H])
cube([JAW_BODY_W - 2*JAW_WALL_T,
PHONE_POCKET_D + JAW_WALL_T,
JAW_BODY_H - JAW_LIP_H + e],
center = [true, false, false]);
// Guide rod bore (M4 clearance, through both guide bosses)
translate([-JAW_BODY_W/2 - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_ROD_D,
h = JAW_BODY_W + GUIDE_BOSS_T + 2*e);
// Spring pocket (coaxial with guide rod, in right boss)
translate([JAW_BODY_W/2 + e, 0, JAW_BODY_H/2])
rotate([0, -90, 0])
cylinder(d = SPRING_OD, h = SPRING_L);
// M4 hex nut pocket in spring-seat wall (end-stop nut)
translate([JAW_BODY_W/2 + GUIDE_BOSS_T + e, 0, JAW_BODY_H/2])
rotate([0, -90, 0])
cylinder(d = M4_NUT_AF / cos(30), h = M4_NUT_H + 0.5,
$fn = 6);
// Cam pivot bore (M4 pivot, through pivot boss)
translate([JAW_BODY_W/2 - e, 0, JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_BORE_D, h = 6 + 2*e);
// Arm attachment bolt holes (M3, to tnut_base arm stubs)
for (bx = [-10, 10])
translate([bx, -JAW_BODY_T/2 - 8 - e, ARM_T/2])
rotate([-90, 0, 0])
cylinder(d = M3_D, h = 12 + 2*e);
// Grip pad seats (recessed Ø1.5 mm, 2 mm deep, optional)
for (pz = [JAW_BODY_H * 0.3, JAW_BODY_H * 0.7])
for (px = [-PAD_W/4, PAD_W/4])
translate([px, -JAW_BODY_T/2 + PHONE_POCKET_D + 1, pz])
rotate([-90, 0, 0])
cylinder(d = M2_D, h = 10);
// Lightening pockets (non-structural core removal)
translate([0, 0, JAW_BODY_H/2])
cube([JAW_BODY_W - 2*JAW_WALL_T - 4,
JAW_BODY_T - 2*JAW_WALL_T,
JAW_BODY_H - JAW_LIP_H - 4],
center = true);
}
}
// ============================================================
// PART 3 SLIDING JAW
// ============================================================
// Upper clamping jaw. Slides along the M4 guide rod.
// Spring pushes this jaw toward the phone (inward).
// M4 hex nut on the guide rod limits maximum travel (full open).
// Cam lever pushes on this jaw face to compress spring (release).
//
// Coordinate origin same convention as fixed_jaw() for assembly.
// Jaw slides in +X direction (away from fixed jaw left wall).
//
// Print jaw-pocket-face down, PETG, 5 perims, 40 % infill.
module sliding_jaw() {
difference() {
union() {
// Main jaw body
translate([-JAW_WALL_T, -JAW_BODY_T/2, 0])
cube([JAW_BODY_W/2 + JAW_WALL_T, JAW_BODY_T, JAW_BODY_H]);
// Front retaining lip
translate([-JAW_WALL_T, JAW_BODY_T/2 - JAW_LIP_T, 0])
cube([JAW_BODY_W/2 + JAW_WALL_T, JAW_LIP_T, JAW_LIP_H]);
// Guide boss (carries guide rod, spring butts against face)
translate([-JAW_WALL_T - GUIDE_BOSS_T, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_BOSS_D, h = GUIDE_BOSS_T);
// Cam follower ear (contacts cam lever)
translate([-JAW_WALL_T - 2, 0,
JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
cube([4, CAM_THICK + 2, CAM_THICK + 2], center = true);
}
// Phone pocket (inner face, contacts phone side)
translate([-JAW_WALL_T - e, -JAW_BODY_T/2 - e, JAW_LIP_H])
cube([JAW_BODY_W/2 - JAW_WALL_T + e,
PHONE_POCKET_D + JAW_WALL_T + 2*e,
JAW_BODY_H - JAW_LIP_H + e]);
// Guide rod bore (M4 clearance through boss + jaw wall)
translate([-JAW_WALL_T - GUIDE_BOSS_T - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_ROD_D,
h = GUIDE_BOSS_T + JAW_WALL_T + 2*e);
// M4 nut pocket (end-stop nut, rear of guide boss)
translate([-JAW_WALL_T - GUIDE_BOSS_T - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = M4_NUT_AF / cos(30), h = M4_NUT_H + 1,
$fn = 6);
// Cam follower bore (M4 pivot passes through ear)
translate([-JAW_WALL_T - 2 - e, 0,
JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_BORE_D, h = 6 + 2*e);
// Grip pad seats
for (pz = [JAW_BODY_H * 0.3, JAW_BODY_H * 0.7])
for (px = [JAW_BODY_W/8])
translate([px, -JAW_BODY_T/2 + PHONE_POCKET_D + 1, pz])
rotate([-90, 0, 0])
cylinder(d = M2_D, h = 10);
}
}
// ============================================================
// PART 4 CAM LEVER (QUICK-RELEASE)
// ============================================================
// Eccentric cam disc + integral handle lever.
// Rotates 90° on M4 pivot pin between CLAMPED and RELEASED states:
// CLAMPED : cam small radius (CAM_R_MIN) toward jaw spring pushes jaw
// RELEASED : cam large radius (CAM_R_MAX) toward jaw compresses spring
// by (CAM_R_MAX - CAM_R_MIN) = 4 mm, opening jaw
//
// Detent ball pocket (Ø3 mm) snaps into rail-dimple for each position.
// Handle points rearward (-Y) in clamped state for low profile.
//
// Print standing on cam edge (cam disc vertical), PETG, 5 perims, 40%.
module cam_lever() {
cam_offset = (CAM_R_MAX - CAM_R_MIN) / 2; // 2 mm eccentricity
union() {
difference() {
union() {
// Eccentric cam disc
// Offset so pivot bore is eccentric to disc profile
translate([cam_offset, 0, 0])
cylinder(r = CAM_R_MAX, h = CAM_THICK, center = true);
// Lever handle arm
hull() {
translate([cam_offset, 0, 0])
cylinder(r = CAM_R_MAX, h = CAM_THICK, center = true);
translate([cam_offset + CAM_HANDLE_L, 0, 0])
cylinder(r = CAM_HANDLE_W/2,
h = CAM_HANDLE_T, center = true);
}
}
// M4 pivot bore (through cam centre)
cylinder(d = CAM_BORE_D, h = CAM_THICK + 2*e, center = true);
// Detent pockets (2× Ø3 mm, at 0° and 90°)
// Pocket at 0° clamped detent
translate([CAM_R_MAX - 2, 0, CAM_THICK/2 - 1.5])
cylinder(d = CAM_DETENT_D + 0.2, h = 2);
// Pocket at 90° released detent
translate([0, CAM_R_MAX - 2, CAM_THICK/2 - 1.5])
cylinder(d = CAM_DETENT_D + 0.2, h = 2);
// Lightening recesses on cam disc face
for (a = [0, 60, 120, 180, 240, 300])
translate([cam_offset + (CAM_R_MAX - 4) * cos(a),
(CAM_R_MAX - 4) * sin(a), 0])
cylinder(d = 4, h = CAM_THICK + 2*e, center = true);
// Handle grip grooves
for (i = [0:4])
translate([cam_offset + 20 + i * 5, 0, 0])
rotate([90, 0, 0])
cylinder(d = 2.5, h = CAM_HANDLE_W + 2*e,
center = true);
}
}
}
// ============================================================
// PART 5 GRIP PAD (VIBRATION DAMPENING)
// ============================================================
// Flat pad with transverse flexure ribs that press against phone side.
// The rib profile (thin PETG fins) provides compliance in Z (vertical)
// absorbing vibration transmitted through the bracket.
// Optional: print in TPU 95A for superior damping.
// M2 bolts or adhesive-backed foam tape attach pad to jaw pocket face.
//
// Pad face (+Y) contacts phone. Mounting face (-Y) bonds to jaw.
// Ribs run parallel to Z axis (vertical).
//
// Print flat (mounting face down), PETG or TPU 95A, 3 perims, 20%.
module grip_pad() {
union() {
// Base plate
translate([-PAD_W/2, -PAD_T, -PAD_H/2])
cube([PAD_W, PAD_T, PAD_H]);
// Flexure ribs (transverse, dampening in Z)
// RIB_COUNT ribs spaced RIB_PITCH apart, centred on pad
for (i = [0 : RIB_COUNT - 1]) {
rx = -PAD_W/2 + RIB_PITCH/2 + i * RIB_PITCH;
if (abs(rx) <= PAD_W/2 - RIB_W/2) // stay within pad
translate([rx, 0, 0])
cube([RIB_W, RIB_H, PAD_H - 4], center = true);
}
// Corner retention nubs (M2 boss for optional bolt-through)
for (px = [-PAD_W/2 + 5, PAD_W/2 - 5])
for (pz = [-PAD_H/2 + 5, PAD_H/2 - 5])
translate([px, -PAD_T/2, pz])
difference() {
cylinder(d = 5, h = PAD_T, center = true);
cylinder(d = M2_D, h = PAD_T + 2*e, center = true);
}
}
}

View File

@ -65,7 +65,7 @@ CLAMP_ALIGN_D = 4.1; // Ø4 pin
// D-cut bore clearance // D-cut bore clearance
DCUT_CL = 0.3; DCUT_CL = 0.3;
// FC mount MAMBA F722S 30.5 × 30.5 mm M3 // FC mount ESP32-S3 BALANCE 30.5 × 30.5 mm M3
FC_PITCH = 30.5; FC_PITCH = 30.5;
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;
// FC is offset toward front of plate (away from stem) // FC is offset toward front of plate (away from stem)
@ -202,7 +202,7 @@ module base_plate() {
translate([STEM_FLANGE_BC/2, 0, -1]) translate([STEM_FLANGE_BC/2, 0, -1])
cylinder(d=M5, h=PLATE_THICK + 2); cylinder(d=M5, h=PLATE_THICK + 2);
// FC mount (MAMBA F722S 30.5 × 30.5 M3) // FC mount (ESP32-S3 BALANCE 30.5 × 30.5 M3)
for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2]) for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2])
for (y = [-FC_PITCH/2, FC_PITCH/2]) for (y = [-FC_PITCH/2, FC_PITCH/2])
translate([x, y, -1]) translate([x, y, -1])

View File

@ -11,7 +11,7 @@
// Ventilation slots all 4 walls + lid // Ventilation slots all 4 walls + lid
// //
// Shared mounting patterns (swappable with SaltyLab): // Shared mounting patterns (swappable with SaltyLab):
// FC : 30.5 × 30.5 mm M3 (MAMBA F722S / Pixhawk) // FC : 30.5 × 30.5 mm M3 (ESP32-S3 BALANCE / Pixhawk)
// Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier) // Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier)
// //
// Coordinate: bay centred at origin; Z=0 = deck top face. // Coordinate: bay centred at origin; Z=0 = deck top face.

View File

@ -1,76 +1,343 @@
// ============================================================ // ============================================================
// rplidar_mount.scad RPLIDAR A1M8 Anti-Vibration Ring Rev A // RPLIDAR A1 Mount Bracket Issue #596
// Agent: sl-mechanical 2026-02-28 // Agent : sl-mechanical
// ============================================================ // Date : 2026-03-14
// Flat ring sits between platform and RPLIDAR A1M8. // Part catalogue:
// Anti-vibration isolation via 4× M3 silicone grommets // 1. tnut_base 2020 T-slot rail interface plate with M5 T-nut captive pockets
// (same type as FC vibration mounts Ø6 mm silicone, M3). // 2. column hollow elevation column, 120 mm tall, 3 stiffening ribs, cable bore
// 3. scan_platform top plate with Ø40 mm BC M3 mounting pattern, vibration seats
// 4. vibe_ring silicone FC-grommet isolation ring for scan_platform bolts
// 5. cable_guide snap-on cable management clip for column body
// //
// Bolt stack (bottom top): // BOM:
// M3×30 SHCS platform (8 mm) grommet (8 mm) // 2 × M5×10 BHCS + M5 T-nuts (tnut_base to rail)
// ring (4 mm) RPLIDAR bottom (threaded M3, ~6 mm engagement) // 4 × M3×8 SHCS (scan_platform to RPLIDAR A1)
// 4 × M3 silicone FC grommets Ø8.5 OD / Ø3.2 bore (anti-vibe)
// 4 × M3 hex nuts (captured in scan_platform)
// //
// RENDER options: // Print settings (PETG):
// "ring" print-ready flat ring (default) // tnut_base / column / scan_platform : 5 perimeters, 40 % gyroid, no supports
// "assembly" ring in position on platform stub // vibe_ring : 3 perimeters, 20 % gyroid, no supports
// cable_guide : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tnut_base"' -o tnut_base.stl rplidar_mount.scad
// openscad -D 'RENDER="column"' -o column.stl rplidar_mount.scad
// openscad -D 'RENDER="scan_platform"' -o scan_platform.stl rplidar_mount.scad
// openscad -D 'RENDER="vibe_ring"' -o vibe_ring.stl rplidar_mount.scad
// openscad -D 'RENDER="cable_guide"' -o cable_guide.stl rplidar_mount.scad
// openscad -D 'RENDER="assembly"' -o assembly.png rplidar_mount.scad
// ============================================================ // ============================================================
RENDER = "ring"; // Render selector
RENDER = "assembly"; // tnut_base | column | scan_platform | vibe_ring | cable_guide | assembly
// RPLIDAR A1M8
RPL_BODY_D = 70.0; // body diameter
RPL_BC = 58.0; // M3 mounting bolt circle
// Mount ring
RING_OD = 82.0; // outer diameter (RPL_BODY_D + 12 mm)
RING_ID = 30.0; // inner cutout (cable / airflow)
RING_H = 4.0; // ring thickness
BOLT_D = 3.3; // M3 clearance through-hole
GROMMET_D = 7.0; // silicone grommet OD (seat recess on bottom)
GROMMET_H = 1.0; // seating recess depth
// Global constants
$fn = 64; $fn = 64;
e = 0.01; EPS = 0.01;
// // 2020 rail
module rplidar_ring() { RAIL_W = 20.0; // extrusion cross-section
difference() { RAIL_H = 20.0;
cylinder(d = RING_OD, h = RING_H); SLOT_NECK_H = 3.2; // T-slot opening width
TNUT_W = 9.8; // M5 T-nut width
TNUT_H = 5.5; // T-nut height (depth into slot)
TNUT_L = 12.0; // T-nut body length
M5_D = 5.2; // M5 clearance bore
M5_HEAD_D = 9.5; // M5 BHCS head diameter
M5_HEAD_H = 4.0; // M5 BHCS head height
// Central cutout // Base plate
translate([0, 0, -e]) BASE_L = 60.0; // length along rail axis
cylinder(d = RING_ID, h = RING_H + 2*e); BASE_W = 30.0; // width across rail
BASE_T = 8.0; // plate thickness
BOLT_PITCH = 40.0; // M5 bolt pitch along rail (centre-to-centre)
// 4× M3 clearance holes on bolt circle // Elevation column
for (a = [45, 135, 225, 315]) { COL_OD = 25.0; // column outer diameter
translate([RPL_BC/2 * cos(a), RPL_BC/2 * sin(a), -e]) COL_ID = 17.0; // inner bore (cable routing)
cylinder(d = BOLT_D, h = RING_H + 2*e); ELEV_H = 120.0; // scan plane above rail top face
} COL_WALL = (COL_OD - COL_ID) / 2;
RIB_W = 3.0; // stiffening rib width
RIB_H = 3.5; // rib radial height
CABLE_SLOT_W = 8.0; // cable entry slot width
CABLE_SLOT_H = 5.0; // cable entry slot height
// Grommet seating recesses bottom face // Scan platform
for (a = [45, 135, 225, 315]) { PLAT_D = 60.0; // platform disc diameter (clears RPLIDAR body Ø100 mm well)
translate([RPL_BC/2 * cos(a), RPL_BC/2 * sin(a), -e]) PLAT_T = 6.0; // platform thickness
cylinder(d = GROMMET_D, h = GROMMET_H + e); RPL_BC_D = 40.0; // RPLIDAR M3 bolt circle diameter (4 bolts at 45 °)
} RPL_BORE_D = 36.0; // central pass-through for scan motor cable
M3_D = 3.2; // M3 clearance bore
M3_NUT_W = 5.5; // M3 hex nut across-flats
M3_NUT_H = 2.4; // M3 hex nut height
GROM_OD = 8.5; // FC silicone grommet OD
GROM_ID = 3.2; // grommet bore
GROM_H = 3.0; // grommet seat depth
CONN_SLOT_W = 12.0; // connector side-exit slot width
CONN_SLOT_H = 5.0; // connector slot height
// Vibe ring
VRING_OD = GROM_OD + 1.6; // printed retainer OD
VRING_ID = GROM_ID + 0.3; // pass-through with grommet seated
VRING_T = 2.0; // ring flange thickness
// Cable guide clip
CLIP_W = 14.0;
CLIP_T = 3.5;
CLIP_GAP = COL_OD + 0.4; // snap-fit gap (slight interference)
SNAP_T = 1.8;
CABLE_CH_W = 8.0;
CABLE_CH_H = 5.0;
// Utility modules
module chamfer_cube(size, ch=1.0) {
// simple chamfered box (bottom edge only for printability)
hull() {
translate([ch, ch, 0])
cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch])
cube(size - [0, 0, ch]);
} }
} }
// module hex_pocket(af, depth) {
// Render selector // hex nut pocket (flat-to-flat af)
// cylinder(d = af / cos(30), h = depth, $fn = 6);
if (RENDER == "ring") {
rplidar_ring();
} else if (RENDER == "assembly") {
// Platform stub
color("Silver", 0.5)
difference() {
cylinder(d = 90, h = 8);
translate([0, 0, -e]) cylinder(d = 25.4, h = 8 + 2*e);
}
// Ring floating 8 mm above (grommet gap)
color("SkyBlue", 0.9)
translate([0, 0, 8 + 8])
rplidar_ring();
} }
// Part 1: tnut_base
module tnut_base() {
difference() {
// Body
union() {
chamfer_cube([BASE_L, BASE_W, BASE_T], ch=1.5);
// Column socket boss centred on plate top face
translate([BASE_L/2, BASE_W/2, BASE_T])
cylinder(d=COL_OD + 4.0, h=8.0);
}
// M5 bolt holes (counterbored for BHCS heads from underneath)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2])
translate([x, BASE_W/2, -EPS]) {
cylinder(d=M5_D, h=BASE_T + 8.0 + 2*EPS);
// counterbore from bottom
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
// T-nut captive pockets (accessible from bottom)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2])
translate([x - TNUT_L/2, BASE_W/2 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
// Column bore into boss
translate([BASE_L/2, BASE_W/2, BASE_T - EPS])
cylinder(d=COL_OD + 0.3, h=8.0 + 2*EPS);
// Cable exit slot through base (offset 5 mm from column centre)
translate([BASE_L/2 - CABLE_SLOT_W/2, BASE_W/2 + COL_OD/4, -EPS])
cube([CABLE_SLOT_W, CABLE_SLOT_H, BASE_T + 8.0 + 2*EPS]);
// Weight relief pockets on underside
for (x = [BASE_L/2 - BOLT_PITCH/2 + 10, BASE_L/2 + BOLT_PITCH/2 - 10])
for (y = [7, BASE_W - 7])
translate([x - 5, y - 5, -EPS])
cube([10, 10, BASE_T/2]);
}
}
// Part 2: column
module column() {
// Actual column height: ELEV_H minus base boss engagement (8 mm) and platform seating (6 mm)
col_h = ELEV_H - 8.0 - PLAT_T;
difference() {
union() {
// Hollow tube
cylinder(d=COL_OD, h=col_h);
// Three 120°-spaced stiffening ribs along full height
for (a = [0, 120, 240])
rotate([0, 0, a])
translate([COL_OD/2 - EPS, -RIB_W/2, 0])
cube([RIB_H, RIB_W, col_h]);
// Bottom spigot (fits into base boss bore)
translate([0, 0, -6.0])
cylinder(d=COL_OD - 0.4, h=6.0 + EPS);
// Top spigot (seats into scan_platform recess)
translate([0, 0, col_h - EPS])
cylinder(d=COL_OD - 0.4, h=6.0);
}
// Inner cable bore
translate([0, 0, -6.0 - EPS])
cylinder(d=COL_ID, h=col_h + 12.0 + 2*EPS);
// Cable entry slot at bottom (aligns with base slot)
translate([-CABLE_SLOT_W/2, -COL_OD/2 - EPS, 2.0])
cube([CABLE_SLOT_W, CABLE_SLOT_H + EPS, CABLE_SLOT_H]);
// Cable exit slot at top (90° rotated for tidy routing)
rotate([0, 0, 90])
translate([-CABLE_SLOT_W/2, -COL_OD/2 - EPS, col_h - CABLE_SLOT_H - 4.0])
cube([CABLE_SLOT_W, CABLE_SLOT_H + EPS, CABLE_SLOT_H]);
// Cable clip snap groove (at mid-height)
translate([0, 0, col_h / 2])
difference() {
cylinder(d=COL_OD + 2*RIB_H + 0.8, h=4.0, center=true);
cylinder(d=COL_OD - 0.2, h=4.0 + 2*EPS, center=true);
}
}
}
// Part 3: scan_platform
module scan_platform() {
difference() {
union() {
// Main disc
cylinder(d=PLAT_D, h=PLAT_T);
// Rim lip for stiffness
translate([0, 0, PLAT_T])
difference() {
cylinder(d=PLAT_D, h=2.0);
cylinder(d=PLAT_D - 4.0, h=2.0 + EPS);
}
}
// Central cable pass-through
translate([0, 0, -EPS])
cylinder(d=RPL_BORE_D, h=PLAT_T + 4.0);
// Column spigot socket (bottom recess)
translate([0, 0, -EPS])
cylinder(d=COL_OD - 0.4 + 0.4, h=6.0);
// RPLIDAR M3 mounting holes 4× on Ø40 BC at 45°/135°/225°/315°
for (a = [45, 135, 225, 315])
rotate([0, 0, a])
translate([RPL_BC_D/2, 0, -EPS]) {
// Through bore
cylinder(d=M3_D, h=PLAT_T + 2*EPS);
// Grommet seat (countersunk from top)
translate([0, 0, PLAT_T - GROM_H])
cylinder(d=GROM_OD + 0.3, h=GROM_H + EPS);
// Captured M3 hex nut pocket (from bottom)
translate([0, 0, 1.5])
hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.2);
}
// Connector side-exit slots (2× opposing, at 0° and 180°)
for (a = [0, 180])
rotate([0, 0, a])
translate([-CONN_SLOT_W/2, PLAT_D/2 - CONN_SLOT_H, -EPS])
cube([CONN_SLOT_W, CONN_SLOT_H + EPS, PLAT_T + 2*EPS]);
// Weight relief pockets (2× lateral)
for (a = [90, 270])
rotate([0, 0, a])
translate([-10, 15, 1.5])
cube([20, 8, PLAT_T - 3.0]);
}
}
// Part 4: vibe_ring
// Printed silicone-grommet retainer ring press-fits over M3 bolt with grommet seated
module vibe_ring() {
difference() {
union() {
cylinder(d=VRING_OD, h=VRING_T + GROM_H);
// Flange
cylinder(d=VRING_OD + 2.0, h=VRING_T);
}
// Bore
translate([0, 0, -EPS])
cylinder(d=VRING_ID, h=VRING_T + GROM_H + 2*EPS);
}
}
// Part 5: cable_guide
// Snap-on cable clip for column mid-section
module cable_guide() {
arm_t = SNAP_T;
gap = CLIP_GAP;
difference() {
union() {
// Saddle body (U-shape wrapping column)
difference() {
cylinder(d=gap + 2*CLIP_T, h=CLIP_W);
translate([0, 0, -EPS])
cylinder(d=gap, h=CLIP_W + 2*EPS);
// Open front slot for snap insertion
translate([-gap/2, 0, -EPS])
cube([gap, gap/2 + CLIP_T + EPS, CLIP_W + 2*EPS]);
}
// Snap arms
for (s = [-1, 1])
translate([s*(gap/2 - arm_t), 0, 0])
mirror([s < 0 ? 1 : 0, 0, 0])
translate([0, -arm_t/2, 0])
cube([arm_t + 1.5, arm_t, CLIP_W]);
// Cable channel bracket (side-mounted)
translate([gap/2 + CLIP_T, -(CABLE_CH_W/2 + CLIP_T), 0])
cube([CLIP_T + CABLE_CH_H, CABLE_CH_W + 2*CLIP_T, CLIP_W]);
}
// Cable channel cutout
translate([gap/2 + CLIP_T + CLIP_T - EPS, -CABLE_CH_W/2, -EPS])
cube([CABLE_CH_H + EPS, CABLE_CH_W, CLIP_W + 2*EPS]);
// Snap tip undercut (both arms)
for (s = [-1, 1])
translate([s*(gap/2 + CLIP_T + 1.0), -arm_t, -EPS])
rotate([0, 0, s*30])
cube([2, arm_t*2, CLIP_W + 2*EPS]);
}
}
// Assembly / render dispatch
module assembly() {
// tnut_base at origin
color("SteelBlue")
tnut_base();
// column rising from base boss
color("DodgerBlue")
translate([BASE_L/2, BASE_W/2, BASE_T + 8.0 - 6.0])
column();
// scan_platform at top of column
col_h_actual = ELEV_H - 8.0 - PLAT_T;
color("CornflowerBlue")
translate([BASE_L/2, BASE_W/2, BASE_T + 8.0 - 6.0 + col_h_actual + 6.0 - EPS])
scan_platform();
// vibe rings (4×) seated in platform holes
for (a = [45, 135, 225, 315])
color("Gray", 0.7)
translate([BASE_L/2, BASE_W/2,
BASE_T + 8.0 - 6.0 + col_h_actual + 6.0 + PLAT_T - GROM_H])
rotate([0, 0, a])
translate([RPL_BC_D/2, 0, 0])
vibe_ring();
// cable_guide clipped at column mid-height
color("LightSteelBlue")
translate([BASE_L/2, BASE_W/2,
BASE_T + 8.0 - 6.0 + (ELEV_H - 8.0 - PLAT_T)/2 - CLIP_W/2])
cable_guide();
}
// Dispatch
if (RENDER == "tnut_base") tnut_base();
else if (RENDER == "column") column();
else if (RENDER == "scan_platform") scan_platform();
else if (RENDER == "vibe_ring") vibe_ring();
else if (RENDER == "cable_guide") cable_guide();
else assembly();

View File

@ -17,7 +17,7 @@
// Weight target: <2 kg frame (excl. motors/electronics) // Weight target: <2 kg frame (excl. motors/electronics)
// //
// Shared SaltyLab patterns (swappable electronics): // Shared SaltyLab patterns (swappable electronics):
// FC : 30.5 × 30.5 mm M3 (MAMBA F722S / Pixhawk) // FC : 30.5 × 30.5 mm M3 (ESP32-S3 BALANCE / Pixhawk)
// Jetson: 58 × 49 mm M3 (Orin NX / Nano carrier board) // Jetson: 58 × 49 mm M3 (Orin NX / Nano carrier board)
// Stem : Ø25 mm bore (sensor head unchanged) // Stem : Ø25 mm bore (sensor head unchanged)
// //
@ -87,7 +87,7 @@ STEM_COLLAR_OD = 50.0;
STEM_COLLAR_H = 20.0; // raised boss height above deck top STEM_COLLAR_H = 20.0; // raised boss height above deck top
STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter
// FC mount MAMBA F722S / Pixhawk (30.5 × 30.5 mm M3) // FC mount ESP32-S3 BALANCE / Pixhawk (30.5 × 30.5 mm M3)
// Shared with SaltyLab swappable electronics // Shared with SaltyLab swappable electronics
FC_PITCH = 30.5; FC_PITCH = 30.5;
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;

View File

@ -1,275 +1,341 @@
// ============================================================ // ============================================================
// uwb_anchor_mount.scad Stem-Mounted UWB Anchor Rev A // uwb_anchor_mount.scad Wall/Ceiling UWB Anchor Mount Bracket
// Agent: sl-mechanical 2026-03-01 // Issue: #564 Agent: sl-mechanical Date: 2026-03-14
// Closes issues #57, #62 // (supersedes Rev A stem-collar mount see git history)
// ============================================================ // ============================================================
// Clamp-on bracket for 2× MaUWB ESP32-S3 anchor modules on
// SaltyBot 25 mm OD vertical stem.
// Anchors spaced ANCHOR_SPACING = 250 mm apart.
// //
// Features: // Parametric wall or ceiling mount bracket for ESP32 UWB Pro anchor.
// Split D-collar with M4 clamping bolts + M4 set screw // Designed for fixed-infrastructure deployment: anchors screw into
// Anti-rotation flat tab that keys against a small pin // wall or ceiling drywall/timber with standard M4 or #6 wood screws,
// OR printed key tab that registers on the stem flat (if stem // at a user-defined tilt angle so the UWB antenna faces the desired
// has a ground flat) see ANTI_ROT_MODE parameter // coverage zone.
// Module bracket: faces outward, tilted 10° from vertical
// so antenna clears stem and faces horizon
// USB cable channel (power from Orin via USB-A) on collar
// Tool-free capture: M4 thumbscrews (slot-head, hand-tighten)
// UWB antenna area: NO material within 10 mm of PCB top face
// //
// Components per mount: // Architecture:
// 2× collar_half print in PLA/PETG, flat-face-down // Wall base -> flat backplate with 2x screw holes (wall or ceiling)
// 1× module_bracket print in PLA/PETG, flat-face-down // Tilt knuckle -> single-axis articulating joint; 15deg detent steps
// locked with M3 nyloc bolt; range 0-90deg
// Anchor cradle-> U-cradle holding ESP32 UWB Pro PCB on M2.5 standoffs
// USB-C channel-> routed groove on tilt arm + exit slot in cradle back wall
// Label slot -> rear window slot for printed anchor-ID card strip
//
// Part catalogue:
// Part 1 -- wall_base() Backplate + 2-ear pivot block + detent arc
// Part 2 -- tilt_arm() Pivoting arm with knuckle + cradle stub
// Part 3 -- anchor_cradle() PCB cradle, standoffs, USB-C slot, label window
// Part 4 -- cable_clip() Snap-on USB-C cable guide for tilt arm
// Part 5 -- assembly_preview()
//
// Hardware BOM:
// 2x M4 x 30mm wood screws (or #6 drywall screws) wall fasteners
// 1x M3 x 20mm SHCS + M3 nyloc nut tilt pivot bolt
// 4x M2.5 x 8mm SHCS PCB-to-cradle
// 4x M2.5 hex nuts captured in standoffs
// 1x USB-C cable anchor power
//
// ESP32 UWB Pro interface (verify with calipers):
// PCB size : UWB_L x UWB_W x UWB_H (55 x 28 x 10 mm default)
// Mounting holes : M2.5, 4x corners on UWB_HOLE_X x UWB_HOLE_Y pattern
// USB-C port : centred on short edge, UWB_USBC_W x UWB_USBC_H
// Antenna area : top face rear half -- 10mm keep-out of bracket material
//
// Tilt angles (15deg detent steps, set TILT_DEG before export):
// 0deg -> horizontal face-up (ceiling, antenna faces down)
// 30deg -> 30deg downward tilt (wall near ceiling) [default]
// 45deg -> diagonal (wall mid-height)
// 90deg -> vertical face-out (wall, antenna faces forward)
// //
// RENDER options: // RENDER options:
// "assembly" single mount assembled (default) // "assembly" full assembly at TILT_DEG (default)
// "collar_front" front collar half for slicing (×2 per mount × 2 mounts = 4) // "wall_base_stl" Part 1
// "collar_rear" rear collar half // "tilt_arm_stl" Part 2
// "bracket" module bracket (×2 mounts) // "anchor_cradle_stl" Part 3
// "pair" both mounts on 350 mm stem section // "cable_clip_stl" Part 4
//
// Export commands:
// openscad uwb_anchor_mount.scad -D 'RENDER="wall_base_stl"' -o uwb_wall_base.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="tilt_arm_stl"' -o uwb_tilt_arm.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="anchor_cradle_stl"' -o uwb_anchor_cradle.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="cable_clip_stl"' -o uwb_cable_clip.stl
// ============================================================ // ============================================================
$fn = 64;
e = 0.01;
// -- Tilt angle (override per anchor, 0-90deg, 15deg steps) ------------------
TILT_DEG = 30;
// -- ESP32 UWB Pro PCB dimensions (verify with calipers) ---------------------
UWB_L = 55.0;
UWB_W = 28.0;
UWB_H = 10.0;
UWB_HOLE_X = 47.5;
UWB_HOLE_Y = 21.0;
UWB_USBC_W = 9.5;
UWB_USBC_H = 4.0;
UWB_ANTENNA_L = 20.0;
// -- Wall base geometry -------------------------------------------------------
BASE_W = 60.0;
BASE_H = 50.0;
BASE_T = 5.0;
BASE_SCREW_D = 4.5;
BASE_SCREW_HD = 8.5;
BASE_SCREW_HH = 3.5;
BASE_SCREW_SPC = 35.0;
KNUCKLE_T = BASE_T + 4.0;
// -- Tilt arm geometry --------------------------------------------------------
ARM_W = 12.0;
ARM_T = 5.0;
ARM_L = 35.0;
PIVOT_D = 3.3;
PIVOT_NUT_AF = 5.5;
PIVOT_NUT_H = 2.4;
DETENT_D = 3.2;
DETENT_R = 8.0;
// -- Anchor cradle geometry ---------------------------------------------------
CRADLE_WALL_T = 3.5;
CRADLE_BACK_T = 4.0;
CRADLE_FLOOR_T = 3.0;
CRADLE_LIP_H = 4.0;
CRADLE_LIP_T = 2.5;
STANDOFF_H = 3.0;
STANDOFF_OD = 5.5;
LABEL_W = UWB_L - 4.0;
LABEL_H = UWB_W * 0.55;
LABEL_T = 1.2;
// -- USB-C routing ------------------------------------------------------------
USBC_CHAN_W = 11.0;
USBC_CHAN_H = 7.0;
// -- Cable clip ---------------------------------------------------------------
CLIP_CABLE_D = 4.5;
CLIP_T = 2.0;
CLIP_BODY_W = 16.0;
CLIP_BODY_H = 10.0;
// -- Fasteners ----------------------------------------------------------------
M2P5_D = 2.7;
M3_D = 3.3;
M3_NUT_AF = 5.5;
M3_NUT_H = 2.4;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly"; RENDER = "assembly";
// Verify with calipers if (RENDER == "assembly") assembly_preview();
MAWB_L = 50.0; // PCB length else if (RENDER == "wall_base_stl") wall_base();
MAWB_W = 25.0; // PCB width else if (RENDER == "tilt_arm_stl") tilt_arm();
MAWB_H = 10.0; // PCB + components else if (RENDER == "anchor_cradle_stl") anchor_cradle();
MAWB_HOLE_X = 43.0; // M2 mounting hole X span else if (RENDER == "cable_clip_stl") cable_clip();
MAWB_HOLE_Y = 20.0; // M2 mounting hole Y span
M2_D = 2.2; // M2 clearance
// Stem // ============================================================
STEM_OD = 25.0; // ASSEMBLY PREVIEW
STEM_BORE = 25.4; // +0.4 clearance // ============================================================
WALL = 2.0; // wall thickness (used in thumbscrew recess) module assembly_preview() {
%color("Wheat", 0.22)
translate([-BASE_W/2, -10, -BASE_H/2])
cube([BASE_W, 10, BASE_H + 40]);
color("OliveDrab", 0.85) wall_base();
color("SteelBlue", 0.85)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0]) tilt_arm();
color("DarkSlateGray", 0.85)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([0, ARM_T, ARM_L]) anchor_cradle();
%color("ForestGreen", 0.38)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([-UWB_L/2, ARM_T+CRADLE_BACK_T,
ARM_L+CRADLE_FLOOR_T+STANDOFF_H])
cube([UWB_L, UWB_W, UWB_H]);
color("DimGray", 0.70)
translate([ARM_W/2, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([0, ARM_T+e, ARM_L/2]) rotate([0,-90,90]) cable_clip();
}
// Collar // ============================================================
COL_OD = 52.0; // PART 1 -- WALL BASE
COL_H = 30.0; // taller than sensor-head collar for rigidity // ============================================================
COL_BOLT_X = 19.0; // M4 bolt CL from stem axis // Flat backplate, 2x countersunk M4/#6 wood screws on 35mm centres.
COL_BOLT_D = 4.5; // M4 clearance // Two pivot ears straddle the tilt arm; M3 pivot bolt through both.
THUMB_HEAD_D= 8.0; // M4 thumbscrew head OD (slot for access) // Detent arc on +X ear inner face: 7 notches at 15deg steps (0-90deg).
COL_NUT_W = 7.0; // M4 hex nut A/F // Shallow rear recess for installation-zone label strip.
COL_NUT_H = 3.4; // Same part for wall mount and ceiling mount.
//
// Anti-rotation flat tab: a 3 mm wall tab that protrudes radially // Print: backplate flat on bed, PETG, 5 perims, 40% gyroid.
// and bears against the bracket arm, preventing axial rotation module wall_base() {
// without needing a stem flat. ear_h = ARM_W + 3.0;
ANTI_ROT_T = 3.0; // tab thickness (radial) ear_t = 6.0;
ANTI_ROT_W = 8.0; // tab width (tangential) ear_sep = ARM_W + 1.0;
ANTI_ROT_Z = 4.0; // distance from collar base
// USB cable channel: groove on collar outer surface, runs Z direction
// Cable routes from anchor module down to base
USB_CHAN_W = 9.0; // channel width (fits USB-A cable Ø6 mm)
USB_CHAN_D = 5.0; // channel depth
// Module bracket
ARM_L = 20.0; // arm length from collar OD to bracket face
ARM_W = MAWB_W + 6.0; // bracket width (Y, includes side walls)
ARM_H = 6.0; // arm thickness (Z)
BRKT_TILT = 10.0; // tilt outward from vertical (antenna faces horizon)
BRKT_BACK_T = 3.0; // bracket back wall (module sits against this)
BRKT_SIDE_T = 2.0; // bracket side walls
M2_STNDFF = 3.0; // M2 standoff height
M2_STNDFF_OD= 4.5;
// USB port access notch in bracket side wall (8×5 mm)
USB_NOTCH_W = 10.0;
USB_NOTCH_H = 7.0;
// Spacing
ANCHOR_SPACING = 250.0; // centre-to-centre Z separation
$fn = 64;
e = 0.01;
//
// collar_half(side)
// split at Y=0 plane. Bracket arm on front (+Y) half.
// Print flat-face-down.
//
module collar_half(side = "front") {
y_front = (side == "front");
difference() { difference() {
union() { union() {
// D-shaped body translate([-BASE_W/2, -BASE_T, -BASE_H/2])
intersection() { cube([BASE_W, BASE_T, BASE_H]);
cylinder(d=COL_OD, h=COL_H); for (ex = [-(ear_sep/2 + ear_t), ear_sep/2])
translate([-COL_OD/2, y_front ? 0 : -COL_OD/2, 0]) translate([ex, -BASE_T+e, -ear_h/2])
cube([COL_OD, COL_OD/2, COL_H]); cube([ear_t, KNUCKLE_T+e, ear_h]);
} for (ex = [-(ear_sep/2 + ear_t), ear_sep/2])
hull() {
// Anti-rotation tab (front half only, at +X side) translate([ex, -BASE_T, -ear_h/4])
if (y_front) { cube([ear_t, BASE_T-1, ear_h/2]);
translate([COL_OD/2, -ANTI_ROT_W/2, ANTI_ROT_Z]) translate([ex + (ex<0 ? ear_t*0.5 : 0), -BASE_T, -ear_h/6])
cube([ANTI_ROT_T, ANTI_ROT_W, cube([ear_t*0.5, 1, ear_h/3]);
COL_H - ANTI_ROT_Z - 4]); }
}
// Bracket arm attachment boss (front half only, top centre)
if (y_front) {
translate([-ARM_W/2, COL_OD/2, COL_H * 0.3])
cube([ARM_W, ARM_L, COL_H * 0.4]);
}
} }
for (sz = [-BASE_SCREW_SPC/2, BASE_SCREW_SPC/2]) {
// Stem bore translate([0, -BASE_T-e, sz]) rotate([-90,0,0])
translate([0,0,-e]) cylinder(d=BASE_SCREW_D, h=BASE_T+2*e);
cylinder(d=STEM_BORE, h=COL_H + 2*e); translate([0, -BASE_T-e, sz]) rotate([-90,0,0])
cylinder(d1=BASE_SCREW_HD, d2=BASE_SCREW_D, h=BASE_SCREW_HH+e);
// M4 clamping bolt holes (Y direction)
for (bx=[-COL_BOLT_X, COL_BOLT_X]) {
translate([bx, y_front ? COL_OD/2 : 0, COL_H/2])
rotate([90,0,0])
cylinder(d=COL_BOLT_D, h=COL_OD/2 + e);
// Thumbscrew head recess on outer face (front only access side)
if (y_front) {
translate([bx, COL_OD/2 - WALL, COL_H/2])
rotate([90,0,0])
cylinder(d=THUMB_HEAD_D, h=8 + e);
}
}
// M4 hex nut pockets (rear half)
if (!y_front) {
for (bx=[-COL_BOLT_X, COL_BOLT_X]) {
translate([bx, -(COL_OD/4 + e), COL_H/2])
rotate([90,0,0])
cylinder(d=COL_NUT_W/cos(30), h=COL_NUT_H + e,
$fn=6);
}
}
// Set screw (height lock, front half)
if (y_front) {
translate([0, COL_OD/2, COL_H * 0.8])
rotate([90,0,0])
cylinder(d=COL_BOLT_D,
h=COL_OD/2 - STEM_BORE/2 + e);
}
// USB cable routing channel (rear half, X side)
if (!y_front) {
translate([-COL_OD/2, -USB_CHAN_W/2, -e])
cube([USB_CHAN_D, USB_CHAN_W, COL_H + 2*e]);
}
// M4 hole through arm boss (Z direction, for bracket bolt)
if (y_front) {
for (dx=[-ARM_W/4, ARM_W/4])
translate([dx, COL_OD/2 + ARM_L/2, COL_H * 0.35])
cylinder(d=COL_BOLT_D, h=COL_H * 0.35 + e);
} }
translate([-(ear_sep/2+ear_t+e), KNUCKLE_T*0.55, 0])
rotate([0,90,0]) cylinder(d=PIVOT_D, h=ear_sep+2*ear_t+2*e);
translate([ear_sep/2+ear_t-PIVOT_NUT_H-0.4, KNUCKLE_T*0.55, 0])
rotate([0,90,0])
cylinder(d=PIVOT_NUT_AF/cos(30), h=PIVOT_NUT_H+0.5, $fn=6);
for (da = [0 : 15 : 90])
translate([ear_sep/2-e,
KNUCKLE_T*0.55 + DETENT_R*sin(da),
DETENT_R*cos(da)])
rotate([0,90,0]) cylinder(d=DETENT_D, h=ear_t*0.45+e);
translate([0, -BASE_T-e, 0]) rotate([-90,0,0])
cube([BASE_W-12, BASE_H-16, 1.6], center=true);
translate([0, -BASE_T+1.5, 0])
cube([BASE_W-14, BASE_T-3, BASE_H-20], center=true);
} }
} }
// // ============================================================
// module_bracket() // PART 2 -- TILT ARM
// Bolts to collar arm boss. Holds MaUWB PCB facing outward. // ============================================================
// Tilted BRKT_TILT° from vertical antenna clears stem. // Pivoting arm linking wall_base ears to anchor_cradle.
// Print flat-face-down (back wall on bed). // Knuckle (Z=0): M3 pivot bore + spring-plunger detent pocket (3mm).
// // Cradle end (Z=ARM_L): 2x M3 bolt attachment stub.
module module_bracket() { // USB-C cable channel groove on outer +Y face, full arm length.
bk = BRKT_BACK_T; //
sd = BRKT_SIDE_T; // Print: knuckle face flat on bed, PETG, 5 perims, 40% gyroid.
module tilt_arm() {
total_h = ARM_L + 10;
difference() {
union() {
translate([-ARM_W/2, 0, 0]) cube([ARM_W, ARM_T, total_h]);
translate([0, ARM_T/2, 0]) rotate([90,0,0])
cylinder(d=ARM_W, h=ARM_T, center=true);
translate([-ARM_W/2, 0, ARM_L])
cube([ARM_W, ARM_T+CRADLE_BACK_T, ARM_T]);
}
translate([-ARM_W/2-e, ARM_T/2, 0]) rotate([0,90,0])
cylinder(d=PIVOT_D, h=ARM_W+2*e);
translate([0, ARM_T+e, 0]) rotate([90,0,0])
cylinder(d=3.2, h=4+e);
translate([-USBC_CHAN_W/2, ARM_T-e, ARM_T+4])
cube([USBC_CHAN_W, USBC_CHAN_H, ARM_L-ARM_T-8]);
for (bx = [-ARM_W/4, ARM_W/4])
translate([bx, ARM_T/2, ARM_L+ARM_T/2]) rotate([90,0,0])
cylinder(d=M3_D, h=ARM_T+CRADLE_BACK_T+2*e);
for (bx = [-ARM_W/4, ARM_W/4])
translate([bx, ARM_T/2, ARM_L+ARM_T/2]) rotate([-90,0,0])
cylinder(d=M3_NUT_AF/cos(30), h=M3_NUT_H+0.5, $fn=6);
translate([0, ARM_T/2, ARM_L/2])
cube([ARM_W-4, ARM_T-2, ARM_L-18], center=true);
}
}
// ============================================================
// PART 3 -- ANCHOR CRADLE
// ============================================================
// Open-front U-cradle for ESP32 UWB Pro PCB.
// 4x M2.5 standoffs on UWB_HOLE_X x UWB_HOLE_Y pattern.
// Back wall: USB-C exit slot + routing groove, label card slot,
// antenna keep-out cutout (material removed above antenna area).
// Front retaining lip prevents PCB sliding out.
// Two attachment tabs bolt to tilt_arm cradle stub via M3.
//
// Label card slot: insert paper/laminate strip to ID this anchor
// (e.g. "UWB-A3 NE-CORNER"), accessible from open cradle end.
//
// Print: back wall flat on bed, PETG, 5 perims, 40% gyroid.
module anchor_cradle() {
outer_l = UWB_L + 2*CRADLE_WALL_T;
outer_w = UWB_W + CRADLE_FLOOR_T;
pcb_z = CRADLE_FLOOR_T + STANDOFF_H;
total_z = pcb_z + UWB_H + 2;
difference() { difference() {
union() { union() {
// Back wall (mounts to collar arm boss) translate([-outer_l/2, 0, 0]) cube([outer_l, outer_w, total_z]);
cube([ARM_W, bk, MAWB_H + M2_STNDFF + 6]); translate([-outer_l/2, outer_w-CRADLE_LIP_T, 0])
cube([outer_l, CRADLE_LIP_T, CRADLE_LIP_H]);
// Side walls for (tx = [-ARM_W/4, ARM_W/4])
for (sx=[0, ARM_W - sd]) translate([tx-4, -CRADLE_BACK_T, 0])
translate([sx, bk, 0]) cube([8, CRADLE_BACK_T+1, total_z]);
cube([sd, MAWB_L + 2, MAWB_H + M2_STNDFF + 6]);
// M2 standoff posts (PCB mounts to these)
for (hx=[0, MAWB_HOLE_X], hy=[0, MAWB_HOLE_Y])
translate([(ARM_W - MAWB_HOLE_X)/2 + hx,
bk + (MAWB_L - MAWB_HOLE_Y)/2 + hy,
0])
cylinder(d=M2_STNDFF_OD, h=M2_STNDFF);
} }
translate([-UWB_L/2, 0, pcb_z]) cube([UWB_L, UWB_W+1, UWB_H+4]);
translate([0, -CRADLE_BACK_T-e, pcb_z+UWB_H/2-UWB_USBC_H/2])
cube([UWB_USBC_W+2, CRADLE_BACK_T+2*e, UWB_USBC_H+2],
center=[true,false,false]);
translate([0, -CRADLE_BACK_T-e, -e])
cube([USBC_CHAN_W, USBC_CHAN_H, pcb_z+UWB_H/2+USBC_CHAN_H],
center=[true,false,false]);
translate([0, -CRADLE_BACK_T-e, pcb_z+UWB_H/2])
cube([LABEL_W, LABEL_T+0.3, LABEL_H], center=[true,false,false]);
translate([0, -e, pcb_z+UWB_H-UWB_ANTENNA_L])
cube([UWB_L-4, CRADLE_BACK_T+2*e, UWB_ANTENNA_L+4],
center=[true,false,false]);
for (tx = [-ARM_W/4, ARM_W/4])
translate([tx, ARM_T/2-CRADLE_BACK_T, total_z/2])
rotate([-90,0,0])
cylinder(d=M3_D, h=ARM_T+CRADLE_BACK_T+2*e);
for (side_x = [-outer_l/2-e, outer_l/2-CRADLE_WALL_T-e])
translate([side_x, 2, pcb_z+2])
cube([CRADLE_WALL_T+2*e, UWB_W-4, UWB_H-4]);
}
for (hx = [-UWB_HOLE_X/2, UWB_HOLE_X/2])
for (hy = [(outer_w-UWB_W)/2 + (UWB_W-UWB_HOLE_Y)/2,
(outer_w-UWB_W)/2 + (UWB_W-UWB_HOLE_Y)/2 + UWB_HOLE_Y])
difference() {
translate([hx, hy, CRADLE_FLOOR_T-e])
cylinder(d=STANDOFF_OD, h=STANDOFF_H+e);
translate([hx, hy, CRADLE_FLOOR_T-2*e])
cylinder(d=M2P5_D, h=STANDOFF_H+4);
}
}
// M2 bores through standoffs // ============================================================
for (hx=[0, MAWB_HOLE_X], hy=[0, MAWB_HOLE_Y]) // PART 4 -- CABLE CLIP
translate([(ARM_W - MAWB_HOLE_X)/2 + hx, // ============================================================
bk + (MAWB_L - MAWB_HOLE_Y)/2 + hy, // Snap-on C-clip retaining USB-C cable along tilt arm outer face.
-e]) // Presses onto ARM_T-wide arm with flexible PETG snap tongues.
cylinder(d=M2_D, h=M2_STNDFF + e); // Print x2-3 per anchor, spaced 25mm along arm.
//
// Antenna clearance cutout in back wall // Print: clip-opening face down, PETG, 3 perims, 20% infill.
// Open slot near top of back wall so antenna is unobstructed module cable_clip() {
translate([sd, -e, M2_STNDFF + 2]) ch_r = CLIP_CABLE_D/2 + CLIP_T;
cube([ARM_W - 2*sd, bk + 2*e, MAWB_H]); snap_t = 1.6;
difference() {
// USB port access notch on one side wall union() {
translate([-e, bk + 2, M2_STNDFF - 1]) translate([-CLIP_BODY_W/2, 0, 0])
cube([sd + 2*e, USB_NOTCH_W, USB_NOTCH_H]); cube([CLIP_BODY_W, CLIP_T, CLIP_BODY_H]);
translate([0, CLIP_T+ch_r, CLIP_BODY_H/2]) rotate([0,90,0])
// Mounting holes to collar arm boss (×2) difference() {
for (dx=[-ARM_W/4, ARM_W/4]) cylinder(r=ch_r, h=CLIP_BODY_W, center=true);
translate([ARM_W/2 + dx, bk + ARM_L/2, -e]) cylinder(r=CLIP_CABLE_D/2, h=CLIP_BODY_W+2*e, center=true);
cylinder(d=COL_BOLT_D, h=6 + e); translate([0, ch_r+e, 0])
cube([CLIP_CABLE_D*0.85, ch_r*2+2*e, CLIP_BODY_W+2*e],
center=true);
}
for (tx = [-CLIP_BODY_W/2+1.5, CLIP_BODY_W/2-1.5-snap_t])
translate([tx, -ARM_T-1, 0])
cube([snap_t, ARM_T+1+CLIP_T, CLIP_BODY_H]);
for (tx = [-CLIP_BODY_W/2+1.5, CLIP_BODY_W/2-1.5-snap_t])
translate([tx+snap_t/2, -ARM_T-1, CLIP_BODY_H/2])
rotate([0,90,0]) cylinder(d=2, h=snap_t, center=true);
}
translate([0, -ARM_T-1-e, CLIP_BODY_H/2])
cube([CLIP_BODY_W-6, ARM_T+2, CLIP_BODY_H-4], center=true);
} }
} }
//
// single_anchor_assembly()
//
module single_anchor_assembly(show_phantom=false) {
// Collar
color("SteelBlue", 0.9) collar_half("front");
color("CornflowerBlue", 0.9) mirror([0,1,0]) collar_half("rear");
// Bracket tilted BRKT_TILT° outward from top of arm boss
color("LightSteelBlue", 0.85)
translate([0, COL_OD/2 + ARM_L, COL_H * 0.3])
rotate([BRKT_TILT, 0, 0])
translate([-ARM_W/2, 0, 0])
module_bracket();
// Phantom UWB PCB
if (show_phantom)
color("ForestGreen", 0.4)
translate([-MAWB_L/2,
COL_OD/2 + ARM_L + BRKT_BACK_T,
COL_H * 0.3 + M2_STNDFF])
cube([MAWB_L, MAWB_W, MAWB_H]);
}
//
// Render selector
//
if (RENDER == "assembly") {
single_anchor_assembly(show_phantom=true);
} else if (RENDER == "collar_front") {
collar_half("front");
} else if (RENDER == "collar_rear") {
collar_half("rear");
} else if (RENDER == "bracket") {
module_bracket();
} else if (RENDER == "pair") {
// Both anchors at 250 mm spacing on a stem stub
color("Silver", 0.2)
translate([0, 0, -50])
cylinder(d=STEM_OD, h=ANCHOR_SPACING + COL_H + 100);
// Lower anchor (Z = 0)
single_anchor_assembly(show_phantom=true);
// Upper anchor (Z = ANCHOR_SPACING)
translate([0, 0, ANCHOR_SPACING])
single_anchor_assembly(show_phantom=true);
}

296
chassis/vesc_mount.scad Normal file
View File

@ -0,0 +1,296 @@
// ============================================================
// vesc_mount.scad FSESC 6.7 Pro Mini Dual ESC Mount Cradle
// Issue #699 / sl-mechanical 2026-03-17
// ============================================================
// Open-top tray for Flipsky FSESC 6.7 Pro Mini Dual (~100 × 68 × 28 mm).
// Attaches to 2020 aluminium T-slot rail via 4× M5 T-nuts
// (2× per rail, two parallel rails, 60 mm bolt spacing in X,
// 20 mm bolt spacing in Y matching 2020 slot pitch).
//
// Connector access:
// XT60 battery inputs X end wall cutouts (2 connectors, side-by-side)
// XT30 motor outputs Y+ and Y side wall cutouts (2 per side wall)
// CAN/UART terminal X+ end wall cutout (screw terminal, wire exit)
//
// Ventilation:
// Open top face heatsink fins fully exposed
// Floor grille slots under-board airflow
// Side vent louvres 4 slots on each Y± wall at heatsink height
//
// Retention: 4× M3 heat-set insert boss in floor board screws down through
// ESC mounting holes via M3×8 FHCS. Board sits on 4 mm raised posts for
// under-board airflow.
//
// VERIFY WITH CALIPERS BEFORE PRINTING:
// PCB_L, PCB_W board outline
// XT60_W, XT60_H XT60 shell at X edge
// XT30_W, XT30_H XT30 shells at Y± edges
// TERM_W, TERM_H CAN screw terminal at X+ edge
// MOUNT_X1/X2, MOUNT_Y1/Y2 ESC board mounting hole pattern
//
// Print settings (PETG):
// 3 perimeters, 40 % gyroid infill, no supports, 0.2 mm layer
// Print orientation: open face UP (as modelled)
//
// BOM:
// 4 × M5×10 BHCS + 4 × M5 slide-in T-nut (2020 rail)
// 4 × M3 heat-set insert (Voron-style, OD 4.5 mm × 4 mm deep)
// 4 × M3×8 FHCS (board retention)
//
// Export commands:
// openscad -D 'RENDER="mount"' -o vesc_mount.stl vesc_mount.scad
// openscad -D 'RENDER="assembly"' -o vesc_assembly.png vesc_mount.scad
// ============================================================
RENDER = "assembly"; // mount | assembly
$fn = 48;
EPS = 0.01;
// Verify before printing
// FSESC 6.7 Pro Mini Dual PCB
PCB_L = 100.0; // board length (X: XT60 end CAN terminal end)
PCB_W = 68.0; // board width (Y)
PCB_T = 2.0; // board thickness (incl. bottom-side components)
COMP_H = 26.0; // tallest component above board top face (heatsink ~26 mm)
// XT60 battery connectors at X end (2 connectors, side-by-side)
XT60_W = 16.0; // each XT60 shell width (Y)
XT60_H = 16.0; // each XT60 shell height (Z) above board surface
XT60_Z0 = 0.0; // connector bottom offset above board surface
// Y centres of each XT60 measured from PCB Y edge
XT60_Y1 = 16.0;
XT60_Y2 = 52.0;
// XT30 motor output connectors at Y± sides (2 per side)
XT30_W = 10.5; // each XT30 shell width (X span)
XT30_H = 12.0; // each XT30 shell height (Z) above board surface
XT30_Z0 = 0.5; // connector bottom offset above board surface
// X centres measured from PCB X edge (same layout both Y and Y+ sides)
XT30_X1 = 22.0;
XT30_X2 = 78.0;
// CAN / UART screw terminal block at X+ end (3-pos 3.5 mm pitch)
TERM_W = 14.0; // terminal block Y span
TERM_H = 10.0; // terminal block height above board surface
TERM_Z0 = 0.5; // terminal bottom offset above board surface
TERM_Y_CTR = PCB_W / 2;
// ESC board mounting hole pattern
// 4 corner holes, 4 mm inset from each PCB edge
MOUNT_INSET = 4.0;
MOUNT_X1 = MOUNT_INSET;
MOUNT_X2 = PCB_L - MOUNT_INSET;
MOUNT_Y1 = MOUNT_INSET;
MOUNT_Y2 = PCB_W - MOUNT_INSET;
M3_INSERT_OD = 4.6; // Voron M3 heat-set insert press-fit OD
M3_INSERT_H = 4.0; // insert depth
M3_CLEAR_D = 3.4; // M3 clearance bore below insert
// Cradle geometry
WALL_T = 2.8; // side / end wall thickness
FLOOR_T = 4.5; // floor plate thickness (fits M5 BHCS head pocket)
POST_H = 4.0; // standoff post height (board lifts off floor for airflow)
CL_SIDE = 0.35; // Y clearance per side
CL_END = 0.40; // X clearance per end
INN_W = PCB_W + 2*CL_SIDE;
INN_L = PCB_L + 2*CL_END;
INN_H = POST_H + PCB_T + COMP_H + 1.5;
OTR_W = INN_W + 2*WALL_T;
OTR_L = INN_L + 2*WALL_T;
OTR_H = FLOOR_T + INN_H;
PCB_X0 = WALL_T + CL_END;
PCB_Y0 = WALL_T + CL_SIDE;
PCB_Z0 = FLOOR_T + POST_H;
// M5 T-nut mount (2020 rail)
// 4 bolts: 2 columns (X) × 2 rows (Y), centred on body
M5_D = 5.3;
M5_HEAD_D = 9.5;
M5_HEAD_H = 3.0;
M5_SPAC_X = 60.0; // X bolt spacing
M5_SPAC_Y = 20.0; // Y bolt spacing (2020 T-slot pitch)
// Floor ventilation grille
GRILLE_SLOT_W = 4.0;
GRILLE_SLOT_T = FLOOR_T - 1.5;
GRILLE_PITCH = 10.0;
GRILLE_X0 = WALL_T + 14;
GRILLE_X_LEN = OTR_L - 2*WALL_T - 28;
GRILLE_N = floor((INN_W - 10) / GRILLE_PITCH);
// Side vent louvres on Y± walls
LOUV_H = 5.0;
LOUV_W = 12.0;
LOUV_Z = FLOOR_T + POST_H + PCB_T + 4.0; // mid-heatsink height
LOUV_N = 4;
LOUV_PITCH = (OTR_L - 2*WALL_T - 20) / max(LOUV_N - 1, 1);
// CAN wire strain relief bosses (X+ end)
SR_BOSS_OD = 7.0;
SR_BOSS_H = 6.0;
SR_SLOT_W = 3.5;
SR_SLOT_T = 2.2;
SR_Y1 = WALL_T + INN_W * 0.25;
SR_Y2 = WALL_T + INN_W * 0.75;
SR_X = OTR_L - WALL_T - SR_BOSS_OD/2 - 2.5;
//
module m3_insert_boss() {
// Solid post with heat-set insert bore from top
post_h = FLOOR_T + POST_H;
difference() {
cylinder(d = M3_INSERT_OD + 3.2, h = post_h);
// Insert bore from top
translate([0, 0, post_h - M3_INSERT_H])
cylinder(d = M3_INSERT_OD, h = M3_INSERT_H + EPS);
// Clearance bore from bottom
translate([0, 0, -EPS])
cylinder(d = M3_CLEAR_D, h = post_h - M3_INSERT_H + EPS);
}
}
module vesc_mount() {
difference() {
union() {
// Main body
cube([OTR_L, OTR_W, OTR_H]);
// M3 insert bosses at board mounting corners
for (mx = [MOUNT_X1, MOUNT_X2])
for (my = [MOUNT_Y1, MOUNT_Y2])
translate([PCB_X0 + mx, PCB_Y0 + my, 0])
m3_insert_boss();
// CAN strain relief bosses on X+ end
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy, 0])
cylinder(d = SR_BOSS_OD, h = SR_BOSS_H);
}
// Interior cavity (open top)
translate([WALL_T, WALL_T, FLOOR_T])
cube([INN_L, INN_W, INN_H + EPS]);
// XT60 cutouts at X end (2 connectors)
for (yc = [XT60_Y1, XT60_Y2])
translate([-EPS,
PCB_Y0 + yc - (XT60_W + 2.0)/2,
PCB_Z0 + XT60_Z0 - 0.5])
cube([WALL_T + 2*EPS, XT60_W + 2.0, XT60_H + 3.0]);
// XT30 cutouts at Y side (2 connectors)
for (xc = [XT30_X1, XT30_X2])
translate([PCB_X0 + xc - (XT30_W + 2.0)/2,
-EPS,
PCB_Z0 + XT30_Z0 - 0.5])
cube([XT30_W + 2.0, WALL_T + 2*EPS, XT30_H + 3.0]);
// XT30 cutouts at Y+ side (2 connectors)
for (xc = [XT30_X1, XT30_X2])
translate([PCB_X0 + xc - (XT30_W + 2.0)/2,
OTR_W - WALL_T - EPS,
PCB_Z0 + XT30_Z0 - 0.5])
cube([XT30_W + 2.0, WALL_T + 2*EPS, XT30_H + 3.0]);
// CAN terminal cutout at X+ end
translate([OTR_L - WALL_T - EPS,
PCB_Y0 + TERM_Y_CTR - (TERM_W + 3.0)/2,
PCB_Z0 + TERM_Z0 - 0.5])
cube([WALL_T + 2*EPS, TERM_W + 3.0, TERM_H + 5.0]);
// Floor ventilation grille
for (i = [0 : GRILLE_N - 1]) {
sy = WALL_T + 5 + i * GRILLE_PITCH;
translate([GRILLE_X0, sy, -EPS])
cube([GRILLE_X_LEN, GRILLE_SLOT_W, GRILLE_SLOT_T + EPS]);
}
// Side vent louvres Y wall
for (i = [0 : LOUV_N - 1]) {
lx = WALL_T + 10 + i * LOUV_PITCH;
translate([lx, -EPS, LOUV_Z])
cube([LOUV_W, WALL_T + 2*EPS, LOUV_H]);
}
// Side vent louvres Y+ wall
for (i = [0 : LOUV_N - 1]) {
lx = WALL_T + 10 + i * LOUV_PITCH;
translate([lx, OTR_W - WALL_T - EPS, LOUV_Z])
cube([LOUV_W, WALL_T + 2*EPS, LOUV_H]);
}
// M5 BHCS head pockets (4 bolts, bottom face)
for (mx = [OTR_L/2 - M5_SPAC_X/2, OTR_L/2 + M5_SPAC_X/2])
for (my = [OTR_W/2 - M5_SPAC_Y/2, OTR_W/2 + M5_SPAC_Y/2])
translate([mx, my, -EPS]) {
cylinder(d = M5_D, h = FLOOR_T + 2*EPS);
cylinder(d = M5_HEAD_D, h = M5_HEAD_H + EPS);
}
// Zip-tie slots through CAN strain relief bosses
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy, SR_BOSS_H/2 - SR_SLOT_T/2])
rotate([0, 90, 0])
cube([SR_SLOT_T, SR_SLOT_W, SR_BOSS_OD + 2*EPS],
center = true);
// Weight-relief pocket in floor underside
translate([WALL_T + 16, WALL_T + 6, -EPS])
cube([OTR_L - 2*WALL_T - 32, OTR_W - 2*WALL_T - 12,
FLOOR_T - 2.0 + EPS]);
}
}
// Assembly preview
if (RENDER == "assembly") {
color("DimGray", 0.93) vesc_mount();
// Phantom PCB
color("ForestGreen", 0.30)
translate([PCB_X0, PCB_Y0, PCB_Z0])
cube([PCB_L, PCB_W, PCB_T]);
// Phantom heatsink / component block
color("SlateGray", 0.22)
translate([PCB_X0, PCB_Y0, PCB_Z0 + PCB_T])
cube([PCB_L, PCB_W, COMP_H]);
// XT60 connector highlights (X end)
for (yc = [XT60_Y1, XT60_Y2])
color("Gold", 0.85)
translate([-2,
PCB_Y0 + yc - XT60_W/2,
PCB_Z0 + XT60_Z0])
cube([WALL_T + 3, XT60_W, XT60_H]);
// XT30 connector highlights Y side
for (xc = [XT30_X1, XT30_X2])
color("OrangeRed", 0.80)
translate([PCB_X0 + xc - XT30_W/2,
-2,
PCB_Z0 + XT30_Z0])
cube([XT30_W, WALL_T + 3, XT30_H]);
// XT30 connector highlights Y+ side
for (xc = [XT30_X1, XT30_X2])
color("OrangeRed", 0.80)
translate([PCB_X0 + xc - XT30_W/2,
OTR_W - WALL_T - 1,
PCB_Z0 + XT30_Z0])
cube([XT30_W, WALL_T + 3, XT30_H]);
// CAN terminal highlight
color("Tomato", 0.75)
translate([OTR_L - WALL_T - 1,
PCB_Y0 + TERM_Y_CTR - TERM_W/2,
PCB_Z0 + TERM_Z0])
cube([WALL_T + 3, TERM_W, TERM_H]);
} else {
vesc_mount();
}

323
docs/AGENTS.md Normal file
View File

@ -0,0 +1,323 @@
# AGENTS.md — SaltyLab Agent Onboarding
You're working on **SaltyLab**, a self-balancing two-wheeled indoor robot. Read this entire file before touching anything.
## ⚠️ 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:
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.
```
Jetson (speed+steer via UART1) ←→ ELRS RC (UART3, kill switch)
ESP32-S3 BALANCE (MPU6000 IMU, PID balance)
▼ UART2
Hoverboard ESC (FOC) → 2× 8" hub motors
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```
Frame: `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
Legacy `src/` STM32 HAL code is **archived — do not extend.**
## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT
This is not a toy. 8" hub motors + 36V battery can crush fingers, break toes, and launch the frame. Every firmware change must preserve these invariants:
1. **Motors NEVER spin on power-on.** Requires deliberate arming: hold button 3s while upright.
2. **Tilt cutoff at ±25°** — motors to zero, require manual re-arm. No retry, no recovery.
3. **Hardware watchdog (50ms)** — if firmware hangs, motors cut.
4. **RC kill switch** — dedicated ELRS channel, checked every loop iteration. Always overrides.
5. **Jetson UART timeout (200ms)** — if Jetson disconnects, motors cut.
6. **Speed hard cap** — firmware limit, start at 10%. Increase only after proven stable.
7. **Never test untethered** until PID is stable for 5+ minutes on a tether.
**If you break any of these, you are removed from the project.**
## Repository Layout
```
<<<<<<< HEAD
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
│ ├── icm42688.c # QMI8658-P SPI driver (backup IMU — currently broken)
│ ├── bmp280.c # Barometer driver (disabled)
│ └── status.c # LED + buzzer status patterns
├── include/
│ ├── config.h # Pin definitions, constants
│ ├── icm42688.h
│ ├── mpu6000.h # MPU6000 driver header (primary IMU)
│ ├── hoverboard.h # Hoverboard ESC UART protocol
│ ├── crsf.h # ELRS CRSF protocol
│ ├── bmp280.h
│ └── status.h
├── lib/USB_CDC/ # USB Serial (CH343) stack (serial over USB)
│ ├── src/ # CDC implementation, USB descriptors, PCD config
│ └── include/
└── platformio.ini # Build config
cad/ # OpenSCAD parametric parts (16 files)
├── dimensions.scad # ALL measurements live here — single source of truth
├── assembly.scad # Full robot assembly visualization
├── motor_mount_plate.scad
├── battery_shelf.scad
├── fc_mount.scad # Vibration-isolated FC mount
├── jetson_shelf.scad
├── esc_mount.scad
├── sensor_tower_top.scad
├── lidar_standoff.scad
├── realsense_bracket.scad
├── bumper.scad # TPU bumpers (front + rear)
├── handle.scad
├── kill_switch_mount.scad
├── tether_anchor.scad
├── led_diffuser_ring.scad
└── esp32c3_mount.scad
ui/ # Web UI (Three.js + WebSerial)
└── index.html # 3D board visualization, real-time IMU data
SALTYLAB.md # Master design doc — architecture, wiring, build phases
SALTYLAB-DETAILED.md # Power budget, weight budget, detailed schematics
PLATFORM.md # Hardware platform 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
| Spec | Value |
|------|-------|
| MCU | ESP32-S3RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Primary IMU | MPU6000 (WHO_AM_I = 0x68) |
| IMU Bus | SPI1: PA5=SCK, PA6=MISO, PA7=MOSI, CS=PA4 |
| IMU EXTI | PC4 (data ready interrupt) |
| IMU Orientation | CW270 (Betaflight convention) |
| Secondary IMU | QMI8658-P (on same SPI1, CS unknown — currently non-functional) |
| Betaflight Target | DIAT-MAMBAF722_2022B |
| USB | OTG FS (PA11/PA12), enumerates as /dev/cu.usbmodemSALTY0011 |
| VID/PID | 0x0483/0x5740 |
| LEDs | PC15 (LED1), PC14 (LED2), active low |
| Buzzer | PB2 (inverted push-pull) |
| Battery ADC | PC1=VBAT, PC3=CURR (ADC3) |
| DFU | Hold yellow BOOT button + plug USB (or send 'R' over CDC) |
### UART Assignments
| UART | Pins | Connected To | Baud |
|------|------|-------------|------|
| USART1 | PA9/PA10 | Jetson Orin Nano Super | 115200 |
| USART2 | PA2/PA3 | Hoverboard ESC | 115200 |
| USART3 | PB10/PB11 | ELRS Receiver | 420000 (CRSF) |
| UART4 | — | Spare | — |
| UART5 | — | Spare | — |
### Motor/ESC
- 2× 8" pneumatic hub motors (36V, hoverboard type)
- Hoverboard ESC with FOC firmware
- UART protocol: `{0xABCD, int16 speed, int16 steer, uint16 checksum}` at 115200
- Speed range: -1000 to +1000
### Physical Dimensions (from `cad/dimensions.scad`)
| Part | Key Measurement |
|------|----------------|
| FC mounting holes | 25.5mm spacing (NOT standard 30.5mm!) |
| FC board size | ~36mm square |
| Hub motor body | Ø200mm (~8") |
| Motor axle | Ø12mm, 45mm long |
| Jetson Orin Nano Super | 100×80×29mm, M2.5 holes at 86×58mm |
| RealSense D435i | 90×25×25mm, 1/4-20 tripod mount |
| RPLIDAR A1 | Ø70×41mm, 4× M2.5 on Ø67mm circle |
| Kill switch hole | Ø22mm panel mount |
| Battery pack | ~180×80×40mm |
| Hoverboard ESC | ~80×50×15mm |
| 2020 extrusion | 20mm square, M5 center bore |
| Frame width | ~350mm (axle to axle) |
| Frame height | ~500-550mm total |
| Target weight | <8kg (current estimate: 7.4kg) |
### 3D Printed Parts (16 files in `cad/`)
| Part | Material | Infill |
|------|----------|--------|
| motor_mount_plate (350×150×6mm) | PETG | 80% |
| battery_shelf | PETG | 60% |
| esc_mount | PETG | 40% |
| jetson_shelf | PETG | 40% |
| sensor_tower_top | ASA | 80% |
| lidar_standoff (Ø80×80mm) | ASA | 40% |
| realsense_bracket | PETG | 60% |
| fc_mount (vibration isolated) | TPU+PETG | — |
| bumper front + rear (350×50×30mm) | TPU | 30% |
| handle | PETG | 80% |
| kill_switch_mount | PETG | 80% |
| tether_anchor | PETG | 100% |
| led_diffuser_ring (Ø120×15mm) | Clear PETG | 30% |
| esp32c3_mount | PETG | 40% |
## Firmware Architecture
### 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.
<<<<<<< HEAD
2. **DCache breaks SPI on ESP32** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
=======
2. **DCache breaks SPI on ESP32-S3** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
3. **`-(int)0 == 0`** — checking `if (-result)` to detect errors doesn't work when result is 0 (success and failure look the same). Always use explicit error codes.
4. **NEVER auto-run untested code on_boot** — we bricked the NSPanel 3x doing this. Test manually first.
5. **USB Serial (CH343) needs ReceivePacket() primed in CDC_Init** — without it, the OUT endpoint never starts listening. No data reception.
### DFU Reboot (Betaflight Method)
The firmware supports reboot-to-DFU via USB command:
1. Send `R` byte over USB Serial (CH343)
2. Firmware writes `0xDEADBEEF` to RTC backup register 0
3. `NVIC_SystemReset()` — clean hardware reset
4. On boot, `checkForBootloader()` (called after `HAL_Init()`) reads the magic
<<<<<<< HEAD
5. If magic found: clears it, remaps system memory, jumps to ESP32 BALANCE bootloader at `0x1FF00000`
=======
5. If magic found: clears it, remaps system memory, jumps to ESP32-S3 bootloader at `0x1FF00000`
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
6. Board appears as DFU device, ready for `dfu-util` flash
### Build & Flash
```bash
cd firmware/
python3 -m platformio run # Build
dfu-util -a 0 -s 0x08000000:leave -D .pio/build/f722/firmware.bin # Flash
```
Dev machine: mbpm4 (seb@192.168.87.40), PlatformIO project at `~/Projects/saltylab-firmware/`
### Clock Configuration
```
HSE 8MHz → PLL (M=8, N=432, P=2, Q=9) → SYSCLK 216MHz
PLLSAI (N=384, P=8) → CLK48 48MHz (USB)
APB1 = HCLK/4 = 54MHz
APB2 = HCLK/2 = 108MHz
Fallback: HSI 16MHz if HSE fails (PLL M=16)
```
## Current Status & Known Issues
### Working
- USB Serial (CH343) serial streaming (50Hz JSON: `{"ax":...,"ay":...,"az":...,"gx":...,"gy":...,"gz":...}`)
- Clock config with HSE + HSI fallback
- Reboot-to-DFU via USB 'R' command
- LED status patterns (status.c)
- Web UI with WebSerial + Three.js 3D visualization
### Broken / In Progress
- **QMI8658-P SPI reads return all zeros** — was the original IMU target, but SPI communication completely non-functional despite correct pin config. May be dead silicon. Switched to MPU6000 as primary.
- **MPU6000 driver** — header exists but implementation needs completion
- **PID balance loop** — not yet implemented
- **Hoverboard ESC UART** — protocol defined, driver not written
- **ELRS CRSF receiver** — protocol defined, driver not written
- **Barometer (BMP280)** — I2C init hangs, disabled
### TODO (Priority Order)
1. Get MPU6000 streaming accel+gyro data
2. Implement complementary filter (pitch angle)
3. Write hoverboard ESC UART driver
4. Write PID balance loop with safety checks
5. Wire ELRS receiver, implement CRSF parser
6. Bench test (ESC disconnected, verify PID output)
7. First tethered balance test at 10% speed
8. Jetson UART integration
9. LED subsystem (ESP32-C3)
## Communication Protocols
### Jetson → FC (UART1, 50Hz)
```c
struct { uint8_t header=0xAA; int16_t speed; int16_t steer; uint8_t mode; uint8_t checksum; };
// mode: 0=idle, 1=balance, 2=follow, 3=RC
```
### FC → Hoverboard ESC (UART2, loop rate)
```c
struct { uint16_t start=0xABCD; int16_t speed; int16_t steer; uint16_t checksum; };
// speed/steer: -1000 to +1000
```
### FC → Jetson Telemetry (UART1 TX, 50Hz)
```
T:12.3,P:45,L:100,R:-80,S:3\n
// T=tilt°, P=PID output, L/R=motor commands, S=state (0-3)
```
### FC → USB Serial (CH343) (50Hz JSON)
```json
{"ax":123,"ay":-456,"az":16384,"gx":10,"gy":-5,"gz":3,"t":250,"p":0,"bt":0}
// Raw IMU values (int16), t=temp×10, p=pressure, bt=baro temp
```
## LED Subsystem (ESP32-C3)
ESP32-C3 eavesdrops on FC→Jetson telemetry (listen-only tap on UART1 TX). No extra FC UART needed.
| State | Pattern | Color |
|-------|---------|-------|
| Disarmed | Slow breathe | White |
| Arming | Fast blink | Yellow |
| Armed idle | Solid | Green |
| Turning | Sweep direction | Orange |
| Braking | Flash rear | Red |
| Fault | Triple flash | Red |
| RC lost | Alternating flash | Red/Blue |
## Printing (Bambu Lab)
- **X1C** (192.168.87.190) — for structural PETG/ASA parts
- **A1** (192.168.86.161) — for TPU bumpers and prototypes
- LAN access codes and MQTT details in main workspace MEMORY.md
- STL export from OpenSCAD, slice in Bambu Studio
## Rules for Agents
1. **Read SALTYLAB.md fully** before making any design decisions
2. **Never remove safety checks** from firmware — add more if needed
3. **All measurements go in `cad/dimensions.scad`** — single source of truth
4. **Test firmware on bench before any motor test** — ESC disconnected, verify outputs on serial
5. **One variable at a time** — don't change PID and speed limit in the same test
6. **Document what you change** — update this file if you add pins, change protocols, or discover hardware quirks
7. **Ask before wiring changes** — wrong connections can fry the FC ($50+ board)

View File

@ -1,6 +1,10 @@
# Face LCD Animation System (Issue #507) # Face LCD Animation System (Issue #507)
Implements expressive face animations on an STM32 LCD display with 5 core emotions and smooth transitions. <<<<<<< 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.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Features ## Features
@ -82,7 +86,11 @@ STATUS → Echo current emotion + idle state
- Colors: Monochrome (1-bit) or RGB565 - Colors: Monochrome (1-bit) or RGB565
### Microcontroller ### Microcontroller
- STM32F7xx (Mamba F722S) <<<<<<< HEAD
- 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) - Available UART: USART3 (PB10=TX, PB11=RX)
- Clock: 216 MHz - Clock: 216 MHz

222
docs/PLATFORM.md Normal file
View File

@ -0,0 +1,222 @@
# SaltyRover — Modular Platform Design 🧂🛞
## Design Philosophy
- **Modular:** Standardized mounting points for swappable top decks
- **Printable:** Main structural brackets on Bambu X1C (256x256x256mm) and A1 (256x256x256mm)
- **Repairable:** Bolt-together, no permanent welds/glue on structural parts
- **Weatherproof-ish:** Splash resistant for outdoor use, not submarine
## Base Platform ("Skateboard")
### Frame
```
FRONT
┌─────────────────┐
│ ┌─M1─┐ ┌─M2─┐ │ M1-M4: 6.5" hub motors
│ │ │ │ │ │ ESC1 drives M1+M2 (front)
│ └────┘ └────┘ │ ESC2 drives M3+M4 (rear)
│ │
│ ┌──────────────┐ │
│ │ BATTERY │ │ Center-mounted battery bay
│ │ BAY │ │ Fits 2x hoverboard packs (2P)
│ └──────────────┘ │
│ │
│ ┌─ESC1─┐┌─ESC2─┐ │ ESCs flanking center
│ └──────┘└──────┘ │
│ ┌──5V──┐┌─12V──┐ │ DC-DC converters
│ └──────┘└──────┘ │
│ │
│ ┌─M3─┐ ┌─M4─┐ │
│ │ │ │ │ │
│ └────┘ └────┘ │
└─────────────────┘
REAR
Overall: ~600mm L × 450mm W × ~120mm H (base only)
```
### Dimensions
- **Length:** 600mm (motor center to motor center ~500mm, +50mm overhang each end)
- **Width:** 450mm (constrained by motor axle-to-axle, ~350mm inner + motor housings)
- **Ground clearance:** ~50mm (bottom of frame to ground)
- **Wheelbase:** 500mm (front axle to rear axle)
- **Track width:** 350mm (left wheel center to right wheel center)
### Frame Construction
- **Main rails (x2):** Aluminum extrusion 2040 V-slot, 600mm length
- Or: 40x20mm aluminum rectangular tube
- Or: 3D printed with steel rod reinforcement
- **Cross members (x3):** Front, center, rear — aluminum or printed
- **Motor mounts (x4):** 3D printed brackets, bolted to frame rails
- Must accommodate 6.5" hub motor axle (standard hoverboard M10 axle)
- Axle clamp style — two-piece with bolts for easy wheel swap
### Modular Top Deck Interface
```
┌─────────────────────┐
│ ○ ○ ○ ○ │ ← M5 threaded inserts, 100mm grid
│ │
│ ○ ○ ○ ○ │ Standard mounting pattern:
│ │ - 400mm × 300mm grid
│ ○ ○ ○ ○ │ - M5 bolt holes on 100mm centers
│ │ - 16 mount points total
│ ○ ○ ○ ○ │
└─────────────────────┘
```
**Top deck connector:**
- 16x M5 threaded inserts in frame top rails
- 100mm grid spacing
- Any top deck just needs matching bolt holes
- Power connector: XT30 (5V + 12V + GND) standardized position at rear-center
- Data connector: USB-A hub mounted to frame, accessible from top
## Top Deck Configurations
### Config 1: "Follow Bot" (Primary)
```
┌─────────────────────┐
│ [RPLIDAR A1M8] │ ← Top-mounted, unobstructed 360°
spinning╲ │ Raised on 100mm standoff
│ │
│ [RealSense D435i] │ ← Front-facing, angled down ~10°
│ │ Height: ~400mm from ground
│ [Jetson Orin Nano Super] │ ← Center, in ventilated enclosure
│ [WiFi/4G module] │ Noctua fan draws air through
│ │
│ [Speaker] [LEDs] │ ← Rear: audio feedback + status
│ [E-STOP button] │ Big red mushroom button
└─────────────────────┘
```
**Parts:**
- Sensor tower: 3D printed, 100mm tall, mounts LIDAR on top
- RealSense bracket: 3D printed, adjustable tilt
- Jetson enclosure: 3D printed, ventilated, vibration dampened
- LED strip ring: NeoPixel/WS2812B around sensor tower (status indication)
### Config 2: "Cargo Hauler"
```
┌─────────────────────┐
│ ┌─────────────────┐ │
│ │ │ │ Flat cargo platform
│ │ CARGO AREA │ │ 400 × 300 × 150mm
│ │ (open top) │ │ With tie-down points
│ │ │ │
│ └─────────────────┘ │
│ [GPS] [Beacon] │ Minimal autonomy — follows beacon
└─────────────────────┘
```
### Config 3: "Camera Rig"
```
┌─────────────────────┐
│ [Gimbal] │ 2-axis stabilized camera mount
│ [Action Cam] │ GoPro / Insta360
│ │
│ [Jetson + storage] │ Records while following
│ [Large battery] │ Extended runtime for filming
└─────────────────────┘
```
### Config 4: "Security Patrol"
```
┌─────────────────────┐
│ [RPLIDAR] │ Autonomous waypoint patrol
│ [PTZ Camera] │ Pan-tilt-zoom camera
│ [Spotlight] │ High-power LED
│ [Jetson + 4G] │ Streams to Frigate
│ [Siren/Speaker] │
└─────────────────────┘
```
## 3D Printed Parts List (Config 1: Follow Bot)
All designed for Bambu X1C/A1 build plate (256x256mm max).
| Part | Size (mm) | Material | Infill | Qty |
|------|-----------|----------|--------|-----|
| Motor mount bracket | 80×60×40 | PETG/ASA | 60% | 4 |
| Motor mount clamp top | 80×40×15 | PETG/ASA | 60% | 4 |
| Cross member front | 350×40×20 | PETG/ASA | 80% | 1 |
| Cross member center | 350×60×20 | PETG/ASA | 80% | 1 |
| Cross member rear | 350×40×20 | PETG/ASA | 80% | 1 |
| Battery tray | 250×150×30 | PETG | 40% | 1 |
| Battery strap anchor | 40×20×15 | PETG | 100% | 4 |
| ESC mount tray | 150×100×15 | PETG | 40% | 2 |
| DC-DC mount | 80×60×15 | PETG | 40% | 2 |
| Sensor tower base | 120×120×10 | ASA | 80% | 1 |
| Sensor tower tube | Ø80×100 | ASA | 40% | 1 |
| LIDAR mount plate | Ø90×5 | ASA | 100% | 1 |
| RealSense bracket | 100×50×60 | PETG | 60% | 1 |
| Jetson enclosure bottom | 120×100×25 | PETG | 40% | 1 |
| Jetson enclosure top | 120×100×25 | PETG | 40% | 1 |
| E-stop mount | 50×50×30 | PETG | 60% | 1 |
| Wire management clips | 20×15×10 | PETG | 100% | 10 |
| Fender/splash guard | 200×80×60 | ASA | 30% | 4 |
**Material notes:**
- **ASA** for outdoor/exposed parts (UV resistant, weather resistant)
- **PETG** for structural internal parts (strong, slight flex)
- Avoid PLA — warps in summer sun
## Electrical Wiring
```
PACK1 ═╤═ PACK2 (parallel, XT60)
├──→ ESC1 ──→ M1 (front-left) + M2 (front-right)
│ │
│ └── UART TX/RX ──→ Jetson GPIO
├──→ ESC2 ──→ M3 (rear-left) + M4 (rear-right)
│ │
│ └── UART TX/RX ──→ Jetson GPIO
├──→ DC-DC 36V→5V ──→ Jetson Orin Nano Super (barrel jack 5V/4A)
│ ──→ USB hub (sensors)
├──→ DC-DC 36V→12V ──→ LED strips
│ ──→ Speaker amp
│ ──→ 4G modem
└──→ E-STOP (normally closed, inline with main power)
```
### ESC UART Protocol (FOC firmware)
- Baud: 115200 (or 9600, configurable)
- Each ESC: `steer` + `speed` as int16 values (-1000 to +1000)
- ESC1 (front): Jetson UART1
- ESC2 (rear): Jetson UART2 (or USB-serial adapter)
### Differential Drive Control
```
Left speed = throttle - steering
Right speed = throttle + steering
For 4WD: front and rear ESCs get same commands
(or: rear slightly less for better turning)
```
## Assembly Order
1. Cut/prepare frame rails (aluminum extrusion or tube)
2. Print all brackets and mounts
3. Assemble frame with cross members
4. Mount motors to brackets, attach to frame
5. Install battery tray, strap packs
6. Mount ESCs and DC-DC converters
7. Wire power distribution (XT60 splitters)
8. Install E-stop inline
9. Mount top deck with sensor tower
10. Wire data connections (UART, USB)
11. First test: power on, spin motors manually via serial terminal
12. Flash follow-bot software to Jetson
13. Outdoor test in parking lot
## Next Steps
- [ ] Measure exact motor axle dimensions and spacing
- [ ] Choose frame material (aluminum extrusion vs printed vs hybrid)
- [ ] Design motor mount bracket in CAD (FreeCAD/Fusion360)
- [ ] Print test motor mount, verify fit
- [ ] Design and print sensor tower
- [ ] Bench test: Jetson → UART → ESC → single motor spinning

454
docs/SALTYLAB-DETAILED.md Normal file
View File

@ -0,0 +1,454 @@
# SaltyLab — Detailed Build Plan 🔬⚖️
Self-balancing two-wheeled indoor robot with AI brain.
---
## 1. Battery Analysis
### Pack Specs (Begode Master V1 packs)
- **Configuration per pack:** 10S (35V nominal, 42V full, 30V cutoff)
- **Chemistry:** Li-ion 18650 or 21700
- **Estimated capacity per pack:** ~450-500Wh (based on Master V1 total ~1800Wh ÷ 4 packs)
- If 10S4P with 21700 5000mAh cells: 36V × 20Ah = **720Wh**
- If 10S3P with 21700 5000mAh cells: 36V × 15Ah = **540Wh**
- If 10S4P with 18650 3500mAh cells: 36V × 14Ah = **504Wh**
- **Need to verify:** check cell count visible on pack, or weigh it
### SaltyLab Battery Config: Single Pack
- **Voltage:** 35V nominal (fits hoverboard ESC: designed for 36V/10S)
- **Capacity:** ~500Wh (conservative estimate)
- **Weight:** ~2-3kg per pack
### Why Single Pack is Enough
- SaltyLab is indoor-only, short missions
- One pack gives 2-4 hours runtime (see estimates below)
- Keep other 3 packs for SaltyRider and SaltyTank
---
## 2. Power Budget & Range Estimation
### Component Power Draw
| Component | Voltage | Current | Power (W) | Notes |
|-----------|---------|---------|-----------|-------|
| Jetson Orin Nano Super | 5V | 2-4A | 10-20W | AI inference mode: ~15W avg |
| RealSense D435i | 5V (USB) | 0.7A | 3.5W | Depth + RGB streaming |
| RPLIDAR A1M8 | 5V | 0.5A | 2.5W | Spinning at 5.5Hz |
| BNO055 IMU | 3.3V | 0.01A | 0.04W | Negligible |
| ESC (idle/balance) | 36V | 0.3A | 10W | Maintaining balance, no movement |
| LEDs + misc | 12V | 0.5A | 6W | Status LEDs, speaker |
| DC-DC losses | — | — | ~5W | ~85% efficiency on converters |
| **Subtotal (idle/balancing)** | | | **~47W** | |
### Motor Power (Moving)
| Activity | Per Motor | Total (2 motors) | Notes |
|----------|-----------|-------------------|-------|
| Balancing in place | 5-15W | 10-30W | Continuous micro-corrections |
| Slow indoor movement (2 km/h) | 15-25W | 30-50W | Walking pace |
| Normal indoor (5 km/h) | 30-50W | 60-100W | Brisk walk |
| Fast / acceleration | 80-150W | 160-300W | Bursts, turning |
| Climbing threshold/ramp | 100-200W | 200-400W | Short duration |
### Total Power by Use Case
| Mode | Electronics | Motors | Total | Notes |
|------|-------------|--------|-------|-------|
| **Idle (balancing)** | 47W | 20W | **~67W** | Standing still |
| **Slow patrol** | 47W | 40W | **~87W** | Gentle movement |
| **Normal follow** | 47W | 80W | **~127W** | Following person around house |
| **Active (turning, accel)** | 47W | 200W | **~247W** | Bursts |
### Range Estimates (Single 500Wh Pack)
| Mode | Avg Power | Runtime | Distance |
|------|-----------|---------|----------|
| Idle (balancing) | 67W | **7.5 hours** | 0 km (stationary) |
| Slow patrol (2 km/h) | 87W | **5.7 hours** | ~11 km |
| Normal follow (5 km/h) | 127W | **3.9 hours** | ~20 km |
| Mixed indoor use | ~100W avg | **5 hours** | ~15 km |
| Aggressive (lots of turning) | 180W avg | **2.8 hours** | ~8 km |
**Bottom line: 3-5 hours of indoor use on a single pack.** More than enough.
### Weight Budget
| Component | Weight (g) | Notes |
|-----------|-----------|-------|
| Battery pack (1x) | 2500 | Estimated, weigh to verify |
| 2x 8" hub motors | 2400 | ~1200g each with tire |
| ESC board | 150 | Single board |
| Jetson Orin Nano Super + heatsink | 280 | With Noctua fan |
| RealSense D435i | 72 | Very light |
| RPLIDAR A1M8 | 170 | With motor |
| BNO055 breakout | 5 | Tiny |
| DC-DC converters (2x) | 300 | 150g each |
| Frame + brackets | 1200 | Aluminum + 3D printed |
| Wiring + connectors | 200 | |
| Bumpers (TPU) | 150 | |
| **TOTAL** | **~7.4 kg** | Target: under 8 kg |
---
## 3. Detailed 2D Schematics
### 3.1 Base Plate — Top View
```
350mm
←─────────────────────────────────────→
┌─────────────────────────────────────┐ ─┬─
│ ○ ○ │ │
│ ┌───────────────────────────┐ │ │
│ │ MOTOR MOUNT PLATE │ │ │
│ │ │ │ │
│ │ ┌─────┐ ┌─────┐ │ │ │
│ │ │AXLE │ │AXLE │ │ │ │ 200mm
│ │ │ L │ │ R │ │ │ │
│ │ └─────┘ └─────┘ │ │ │
│ │ ↑ 250mm ↑ │ │ │
│ │ └─────────────┘ │ │ │
│ │ track width │ │ │
│ └───────────────────────────┘ │ │
│ ○ ○ │ │
└─────────────────────────────────────┘ ─┴─
○ = M5 mounting holes for vertical spine (4 corners)
Axle holes: Ø14mm (standard hoverboard axle)
Plate thickness: 6mm PETG
Motor mount detail:
┌──────────────┐
│ ┌────────┐ │
│ │ Ø14mm │ │ Two-piece clamp:
│ │ axle │ │ Bottom: part of base plate
│ │ hole │ │ Top: clamp plate with 2x M6 bolts
│ └────────┘ │
│ ○ ○ │ ○ = M6 clamp bolt holes
└──────────────┘
80mm
```
### 3.2 Side View — Full Assembly
```
FRONT →
550mm ┬ ┌─────────┐
│ │ RPLIDAR │ Ø80mm, 360° clear
│ │ A1M8 │
500mm │ ├─────────┤
│ │ │ ← LIDAR standoff tube (80mm tall)
│ │ │
420mm │ ├─────────┤
│ │RealSense│ ← Tilted down 10°, front-facing
│ │ D435i │ Adjustable bracket
380mm │ ├─────────┤
│ │ │
│ │ JETSON │ ← Noctua fan, ventilation slots
│ │ NANO │
300mm │ ├─────────┤
│ │ BNO055 │ ← IMU, vibration-isolated mount
280mm │ ├─────────┤
│ │ │
│ │ ESC + │ ← ESC board + DC-DC converters
│ │ DC-DCs │
200mm │ ├─────────┤
│ │ │
│ │ BATTERY │ ← Heaviest component, lowest position
│ │ PACK │ Strapped to spine with velcro
│ │ │
80mm │ ├─────────┤
│ │ BASE │ ← Motor mount plate (6mm)
│ │ PLATE │
40mm │ ├────┬────┤
│ │ │ │
┴ └────┘ └── 8" wheel (Ø203mm)
═════════════
GROUND (0mm)
Ground clearance: ~40mm (bottom of plate to ground)
Wheel contact to axle center: ~100mm (8" diameter / 2)
Axle height from ground: ~100mm
```
### 3.3 Front View
```
←── 350mm ──→
┌─────────┐ ─┬─ 550mm
│ RPLIDAR │ │
├─────────┤ │
│ ┃ │ ← spine │
│ ┃ │ (2020 │
│ ┃ │ extrusion│
│ ┃ │ or │
│ ┃ │ aluminum │
│ ┃ │ tube) │
│ ┃ │ │
├───┃─────┤ │
┌─────┐ │ ┃ │ ┌─────┐│
│ │ │ ┃ │ │ ││
│ 8" │ │ ┃ │ │ 8" ││ 100mm
│ L │───┤ ┃ ├───│ R ││ (axle)
│ │ │ ┃ │ │ ││
│ │ └───┃─────┘ │ │┴─ 0mm
└─────┘ ┃ └─────┘
═══════════════════════════════════
←65→←──── 250mm ────→←65→
mm (track width) mm
Total width with tires: ~380mm
Fits through standard doorway (760mm) ✓
```
### 3.4 Spine Detail — Side View
```
┌──┐ ← 20×20mm aluminum extrusion (2020 V-slot)
│ │ or 25×25mm square aluminum tube
│ │
│ │──── Shelf bracket (3D printed, bolts to T-slot)
│ │ Each shelf: 120mm wide × 100-150mm deep
│ │
│ │──── Shelf bracket
│ │
│ │──── Shelf bracket
│ │
│ │──── Shelf bracket
│ │
├──┤
│ │──── Base plate connection (L-brackets, 4x M5)
└──┘
Spine length: 470mm (from base plate to LIDAR mount)
Shelf positions (from base plate):
0mm — Base plate
30mm — Battery shelf (holds pack on its side)
150mm — ESC + DC-DC shelf
250mm — Jetson Orin Nano Super shelf
300mm — BNO055 (attached to spine directly)
370mm — RealSense bracket (front-facing arm)
420mm — LIDAR standoff begins
500mm — LIDAR mount plate
```
### 3.5 Wiring Diagram
```
BATTERY PACK (35V nominal, 10S Li-ion)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│(+) ()│
│ │
├──[E-STOP (NC)]───────────┤
│ │
XT60 ├────────┬────────┬────────┤ XT60
│ │ │ │
│ ┌───┴───┐ │ │
│ │DC-DC │ │ │
│ │36V→5V │ │ │
│ │ 4A │ │ │
│ └───┬───┘ │ │
│ 5V │ │ │
│ ┌───┴────┐ │ │
│ │USB Hub │ │ │
│ │Jetson │ │ │
│ │RealSns │ │ │
│ │RPLIDAR │ │ │
│ └────────┘ │ │
│ │ │
│ ┌────┴───┐ │ │
│ │DC-DC │ │ │
│ │36V→12V │ │ │
│ │ 2A │ │ │
│ └───┬────┘ │ │
│ 12V │ │ │
│ ┌───┴────┐ │ │
│ │LEDs │ │ │
│ │Speaker │ │ │
│ └────────┘ │ │
│ │ │
┌────┴─────────────────┴────────┴────┐
│ HOVERBOARD ESC │
│ (FOC firmware) │
│ │
│ I2C: SDA──BNO055──SCL │
│ UART: TX──Jetson──RX │
│ │
│ PHASE L: ─── 8" LEFT MOTOR │
│ HALL L: ─── (5 wire: hall A/B/C │
│ + 5V + GND) │
│ │
│ PHASE R: ─── 8" RIGHT MOTOR │
│ HALL R: ─── (5 wire) │
└────────────────────────────────────┘
```
---
## 4. Phased Build Plan
### Phase 1: Rolling Skeleton (Days 1-3)
**Goal:** Prove balance works.
**Tasks:**
- [ ] Measure 8" motor axle diameter with calipers
- [ ] Design motor mount plate in CAD (FreeCAD or TinkerCAD)
- [ ] Print motor mount plate on Bambu X1C (PETG, 80% infill, ~4h print)
- [ ] Print axle clamp tops (x2)
- [ ] Mount both 8" motors to plate
- [ ] Mount ESC to plate with standoffs
- [ ] Wire BNO055 to ESC I2C (4 wires: VCC, GND, SDA, SCL)
- [ ] Wire battery to ESC (XT60)
- [ ] Modify FOC firmware: add BNO055 I2C read, replace gyro board input
- [ ] Flash ESC with updated firmware
- [ ] **SAFETY:** Tie rope from ceiling to plate as fall catch
- [ ] Power on, tune PID (start with Kp=20, Ki=0, Kd=0, increase gradually)
- [ ] Achieve stable free-standing balance (no rope)
**Parts needed:** motor mount plate, 2x clamp tops, M6 bolts, M3 standoffs
**Risk:** PID tuning can take hours of iteration. Be patient.
### Phase 2: Spine + Brain (Days 4-7)
**Goal:** Add Jetson, achieve remote-controlled movement while balancing.
**Tasks:**
- [ ] Cut aluminum extrusion/tube to 470mm for spine
- [ ] Print shelf brackets (4x) and L-brackets for base connection
- [ ] Assemble spine onto base plate
- [ ] Mount battery to lowest shelf (velcro straps)
- [ ] Mount ESC + DC-DC converters
- [ ] Mount Jetson Orin Nano Super on shelf, connect 5V power
- [ ] Wire Jetson UART → ESC UART
- [ ] Install JetPack 4.6 on Jetson (if not already)
- [ ] Write serial bridge: Jetson Python → ESC UART commands
- [ ] Test: keyboard control (WASD) → speed/steer commands → balanced movement
- [ ] Tune speed response (acceleration limits, max speed for indoor)
- [ ] Add E-stop button (inline with battery positive)
**Software deliverable:** `saltylab_teleop.py` — keyboard-controlled balancing bot
### Phase 3: Eyes + Ears (Days 8-12)
**Goal:** See the world. Map a room. Detect people.
**Tasks:**
- [ ] Print RealSense bracket (adjustable tilt)
- [ ] Print LIDAR standoff tube + mount plate
- [ ] Mount RealSense D435i (front-facing, ~10° down tilt)
- [ ] Mount RPLIDAR A1M8 (top of spine, 360° clear)
- [ ] Install ROS2 on Jetson
- [ ] Install and test `realsense-ros` (verify depth stream)
- [ ] Install and test `rplidar_ros` (verify laser scan)
- [ ] Run `slam_toolbox` — drive around room, build 2D map
- [ ] Test person detection with SSD-MobileNet-v2 (TensorRT)
- [ ] Implement follow mode:
- Detect person in RGB frame
- Get distance from depth frame
- PID to maintain 1.5m following distance
- LIDAR for obstacle avoidance
**Software deliverable:** `saltylab_follow.py` — person-following balanced bot
### Phase 4: Polish + Personality (Days 13-17)
**Goal:** Make it feel alive. Make it SaltyLab.
**Tasks:**
- [ ] Print proper enclosures for all electronics
- [ ] Print TPU bumpers (front + rear)
- [ ] Print carry handle
- [ ] Add NeoPixel LED ring around LIDAR mount (status indication)
- Blue breathing: idle/balancing
- Green: following
- Yellow: exploring/mapping
- Red: error/low battery
- [ ] Add small speaker (USB or I2S to amp)
- Boot sound
- Acknowledge commands with beeps/chirps
- Optional: TTS via Jetson ("I see you", "battery low")
- [ ] WiFi dashboard: live camera feed + map + battery status
- [ ] Battery voltage monitoring (ADC on ESC → Jetson via UART)
- [ ] Low battery return behavior (stop and beep)
- [ ] Integrate with Home Assistant (MQTT: location, battery, status)
### Phase 5: House Mapping + Autonomy (Days 18-24)
**Goal:** SaltyLab knows your house.
**Tasks:**
- [ ] Map every room (drive around manually, SLAM builds full floor plan)
- [ ] Save map, set up `nav2` for autonomous navigation
- [ ] Define waypoints: lab, living room, kitchen, hallway
- [ ] Patrol mode: visit waypoints on schedule
- [ ] Person detection + greeting ("hey Tee", "hi Inka")
- [ ] Integration with Bermuda BLE: know where people are, go to them
- [ ] Charging dock design (future: auto-dock when low)
---
## 5. Speed & Performance Specs
### Target Performance
| Parameter | Value | Notes |
|-----------|-------|-------|
| Max speed (indoor) | 5 km/h | Software limited for safety |
| Normal follow speed | 2-3 km/h | Walking pace |
| Turning radius | 0 (pivot) | Differential drive, spins in place |
| Ground clearance | 40mm | Clears door thresholds (~15mm) |
| Max incline | ~10° | Limited by motor torque + balance |
| Operating time | 3-5 hours | Single 500Wh pack |
| Charge time | ~2-3 hours | Using one of the existing chargers |
| Weight | ~7.5 kg | Easy to pick up with handle |
| Width | 380mm | Fits all doorways |
| Height | 550mm | Below table height |
### Motor Specs (8" hub motor, estimated)
| Parameter | Value |
|-----------|-------|
| Nominal voltage | 36V |
| Rated power | 250-350W per motor |
| No-load RPM | ~250 RPM |
| Wheel circumference | ~0.64m (Ø203mm) |
| Max wheel speed | 160 m/min = 9.6 km/h |
| Continuous torque | ~2-3 Nm |
| Stall torque | ~8-10 Nm |
---
## 6. Safety Considerations
| Hazard | Mitigation |
|--------|-----------|
| Falls over | TPU bumpers, max tilt cutoff (30°), low CoG |
| Runs away | Software speed limit (5 km/h), E-stop button |
| Pinches/crushes | No exposed gears, motor covers |
| Battery fire | BMS on pack, fused main power, no charging unattended |
| Hits furniture | LIDAR obstacle avoidance, bumper sensors (future) |
| Scares the cat | Slow acceleration, no sudden movements |
---
## 7. Shopping List (Items NOT in Inventory)
| Item | Price (CAD) | Source | Notes |
|------|------------|--------|-------|
| 2020 aluminum extrusion 500mm | ~$8 | Amazon/AliExpress | Spine |
| T-slot nuts + M5 bolts (pack) | ~$12 | Amazon | For shelf mounting |
| M6 bolts + nuts (axle clamps) | ~$5 | Hardware store | 4x sets |
| NeoPixel ring (24 LED) | ~$8 | Amazon | Status indication |
| Small speaker + amp (MAX98357A) | ~$10 | Amazon/Adafruit | I2S audio |
| E-stop mushroom button | ~$5 | Amazon | Safety |
| XT60 splitter/distribution | ~$8 | Amazon | Power wiring |
| Misc: heat shrink, zip ties, wire | ~$10 | — | Always need more |
| **TOTAL** | **~$66** | | Everything else: already owned |
---
*Last updated: 2026-02-27*
*Project: SaltyRover / SaltyLab*

296
docs/SALTYLAB.md Normal file
View File

@ -0,0 +1,296 @@
# SAUL-TEE — Self-Balancing Wagon Robot 🔬
Four-wheel wagon (870×510×550 mm, 23 kg). Full spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md`
## ⚠️ SAFETY — TOP PRIORITY
**This robot can cause serious injury.** 8" hub motors with 36V power can crush toes, break fingers, and launch the frame if control is lost. Every design decision must prioritize safety.
### Mandatory Safety Systems
1. **Hardware kill switch** — physical big red button, wired inline with battery. Cuts ALL power instantly. Must be reachable without approaching the wheels.
2. **Software tilt cutoff** — if pitch exceeds ±25° (not 30°), motors go to zero immediately. No retry, no recovery. Requires manual re-arm.
3. **Startup arming sequence** — motors NEVER spin on power-on. Requires deliberate arming: hold button for 3 seconds while robot is upright and stable.
4. **Watchdog timeout** — if FC firmware hangs or crashes, hardware watchdog resets to safe state (motors off) within 50ms.
5. **Current limiting** — hoverboard ESC max current set conservatively. Start low, increase gradually.
6. **Tether during development** — ceiling rope/strap during ALL balance testing. No free-standing tests until PID is proven stable for 5+ minutes tethered.
7. **Speed limiting** — firmware hard cap on max speed. Start at 10% throttle, increase in 10% increments only after stable testing.
8. **Remote kill** — Jetson can send emergency stop via UART. If Jetson disconnects (UART timeout >200ms), FC cuts motors automatically.
9. **Bumpers** — TPU bumpers on all sides, mandatory before any untethered operation.
10. **Test area** — clear 3m radius, no pets/kids/cables. Shoes mandatory.
11. **RC kill channel** — ELRS receiver connected to FC UART. Dedicated switch on radio = instant disarm. Works independently of Jetson. Always have radio in hand during testing.
### Safety Rules for Development
- **Never reach near wheels while powered** — even "stopped" motors can spike
- **Never test new firmware untethered** — tether FIRST, always
- **Never increase speed and change PID in the same test** — one variable at a time
- **Log everything** — FC sends telemetry (pitch, PID output, motor commands) to Jetson for post-crash analysis
- **Two people for early tests** — one at the kill switch, one observing
## Parts
| Part | Status |
|------|--------|
| 2x 8" pneumatic hub motors (36 PSI) | ✅ Have |
| 1x hoverboard ESC (FOC firmware) | ✅ Have |
| 1x Drone FC (ESP32-S3 + QMI8658) | ✅ Have — balance brain |
| 1x Jetson Orin Nano Super + Noctua fan | ✅ Have |
| 1x RealSense D435i | ✅ Have |
| 1x RPLIDAR A1M8 | ✅ Have |
| 1x battery pack (36V) | ✅ Have |
| 1x DC-DC 5V converter | ✅ Have |
| 1x DC-DC 12V converter | ✅ Have |
| 1x ESP32-C3 (LED controller) | ⬜ Need (~$3) |
| WS2812B LED strip (60/m) | ⬜ Need |
| BNO055 9-DOF IMU | ✅ Have (spare/backup) |
| MPU6050 | ✅ Have (spare/backup) |
| 1x Big red kill switch (NC, inline with battery) | ⬜ Need |
| 1x Arming button (momentary, with LED) | ⬜ Need |
| 1x Ceiling tether strap + carabiner | ⬜ Need |
| 1x BetaFPV ELRS 2.4GHz 1W TX module | ✅ Have — RC control + kill switch |
| 1x ELRS receiver (matching) | ✅ Have — mounts on FC UART |
### ESP32-S3 BALANCE Board Details — Waveshare ESP32-S3 Touch LCD 1.28
- **MCU:** ESP32-S3RET6 (Xtensa LX7 dual-core, 240MHz, 8MB Flash, 512KB SRAM)
- **IMU:** QMI8658 (6-axis, 32kHz gyro, ultra-low noise, SPI) ← the good one!
- **Display:** 1.28" round LCD (GC9A01 driver, 240x240)
- **DFU mode:** Hold BOOT button while plugging USB
- **Firmware:** Custom balance firmware (ESP-IDF / Arduino-ESP32)
- **USB:** USB Serial via CH343 chip
- **UART assignments:**
- UART0 → USB Serial (CH343) → debug/flash
- UART1 → Jetson Orin Nano Super
- UART2 → Hoverboard ESC
- UART3 → ELRS receiver
- UART4/5 → spare
## Architecture
```
┌──────────────┐
│ RPLIDAR A1 │ ← 360° scan, top-mounted
└──────┬───────┘
┌──────┴───────┐
│ RealSense │ ← Forward-facing depth+RGB
│ D435i │
├──────────────┤
│ Jetson Orin Nano Super │ ← AI brain: navigation, person tracking
│ │ Sends velocity commands via UART
├──────────────┤
│ Drone FC │ ← Balance brain: IMU + PID @ 8kHz
│ F745+MPU6000 │ Custom firmware, UART out to ESC
├──────────────┤
│ Battery 36V │
│ + DC-DCs │
├──────┬───────┤
┌─────┤ ESC (FOC) ├─────┐
│ │ Hoverboard │ │
│ └──────────────┘ │
┌──┴──┐ ┌──┴──┐
│ 8" │ │ 8" │
│ LEFT│ │RIGHT│
└─────┘ └─────┘
```
## Self-Balancing Control — ESP32-S3 BALANCE Board
> For full system architecture, firmware details, and protocol specs, see
> **docs/SAUL-TEE-SYSTEM-REFERENCE.md**
The balance controller runs on the Waveshare ESP32-S3 Touch LCD 1.28 board
(ESP32-S3 BALANCE). It reads the onboard QMI8658 IMU at 8kHz, runs a PID
balance loop, and drives the hoverboard ESC via UART. Jetson Orin Nano Super
sends velocity commands over UART1. ELRS receiver on UART3 provides RC
override and kill-switch capability.
The legacy STM32 firmware (Mamba F722S era) has been archived to
=======
The legacy STM32 firmware (STM32 era) has been archived to
`legacy/stm32/` and is no longer built or deployed.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## LED Subsystem (ESP32-C3)
### Architecture
The ESP32-C3 eavesdrops on the FC→Jetson telemetry UART line (listen-only, one wire).
No extra UART needed on the FC — zero firmware change.
```
FC UART1 TX ──┬──→ Jetson RX
└──→ ESP32-C3 RX (listen-only, same wire)
└──→ WS2812B strip (via RMT peripheral)
```
### Telemetry Format (already sent by FC at 50Hz)
```
T:12.3,P:45,L:100,R:-80,S:3\n
^-- State byte: 0=disarmed, 1=arming, 2=armed, 3=fault
```
ESP32-C3 parses the `S:` field and `L:/R:` for turn detection.
### LED Patterns
| State | Pattern | Color |
|-------|---------|-------|
| Disarmed | Slow breathe | White |
| Arming | Fast blink | Yellow |
| Armed idle | Solid | Green |
| Turning left | Sweep left | Orange |
| Turning right | Sweep right | Orange |
| Braking | Flash rear | Red |
| Fault | Triple flash | Red |
| RC signal lost | Alternating flash | Red/Blue |
### Turn/Brake Detection (on ESP32-C3)
```
if (L - R > threshold) → turning right
if (R - L > threshold) → turning left
if (L < -threshold && R < -threshold) braking
```
### Wiring
```
FC UART1 TX pin ──→ ESP32-C3 GPIO RX (e.g. GPIO20)
ESP32-C3 GPIO8 ──→ WS2812B data in
ESC 5V BEC ──→ ESP32-C3 5V + WS2812B 5V
GND ──→ Common ground
```
### Dev Tools
- **Flashing:** ESP32-S3CubeProgrammer via USB (DFU mode) or SWD
- **IDE:** PlatformIO + ESP-IDF, or ESP32-S3CubeIDE
- **Debug:** SWD via ST-Link (or use FC's USB as virtual COM for printf debug)
## Physical Design
### Frame: Vertical Tower
```
SIDE VIEW FRONT VIEW
┌───────────┐ ┌─────────────────┐
│ RPLIDAR │ ~500mm │ RPLIDAR │
├───────────┤ ├─────────────────┤
│ RealSense │ ~400mm │ [RealSense] │
├───────────┤ ├─────────────────┤
│ Jetson │ ~300mm │ [Jetson] │
├───────────┤ ├─────────────────┤
│ Drone FC │ ~200mm │ [Drone FC] │
├───────────┤ ├─────────────────┤
│ Battery │ ~100mm │ [Battery] │
│ + ESC │ LOW! │ [ESC+DCDC] │
├─────┬─────┤ ├──┬──────────┬───┤
│ │ │ │ │ │ │
─┘ └─────┘─ ─┘ 8" 8" └──┘─
═══════════════ ═══ ═══
GROUND L R
```
### Key Dimensions
- **Height:** ~500-550mm total (sensor tower top)
- **Width:** ~350mm (axle to axle, constrained by motors)
- **Depth:** ~150-200mm (thin profile for doorways)
- **Weight target:** <10kg including battery
- **Center of gravity:** AS LOW AS POSSIBLE — battery + ESC at bottom
### Critical: Center of Mass
- Battery is the heaviest component → mount at axle height or below
- Jetson + sensors are light → can go higher
- Lower CoG = easier to balance, less aggressive PID needed
- If CoG is too high → oscillations, falls easily
### Frame Material
- **Main spine:** Aluminum extrusion 2020, vertical
- **Motor mount plate:** 3D printed PETG, 6mm thick, reinforced
- **Component shelves:** 3D printed PETG, bolt to spine
- **Fender/bumper:** 3D printed TPU (flexible, absorbs falls)
### 3D Printed Parts
| Part | Size (mm) | Material | Qty |
|------|-----------|----------|-----|
| Motor mount plate | 350×150×6 | PETG 80% | 1 |
| Battery shelf | 200×100×40 | PETG 60% | 1 |
| ESC mount | 150×100×15 | PETG 40% | 1 |
| Jetson shelf | 120×100×15 | PETG 40% | 1 |
| Sensor tower top | 120×120×10 | ASA 80% | 1 |
| LIDAR standoff | Ø80×80 | ASA 40% | 1 |
| RealSense bracket | 100×50×40 | PETG 60% | 1 |
| FC mount (vibration isolated) | 30×30×15 | TPU+PETG | 1 |
| Bumper front | 350×50×30 | TPU 30% | 1 |
| Bumper rear | 350×50×30 | TPU 30% | 1 |
| Handle (for carrying) | 150×30×30 | PETG 80% | 1 |
| Kill switch mount | 60×60×40 | PETG 80% | 1 |
| Tether anchor point | 50×50×20 | PETG 100% | 1 |
| LED diffuser ring | Ø120×15 | Clear PETG 30% | 1 |
| ESP32-C3 mount | 30×25×10 | PETG 40% | 1 |
## Software Stack
### Jetson Orin Nano Super
- **OS:** JetPack 4.6.1 (Ubuntu 18.04)
- **ROS2 Humble** (or Foxy) for:
- `nav2` — navigation stack
- `slam_toolbox` — 2D SLAM from LIDAR
- `realsense-ros` — depth camera
- `rplidar_ros` — LIDAR driver
- **Person following:** SSD-MobileNet-v2 via TensorRT (~20 FPS)
- **Balance commands:** ROS topic → UART bridge to drone FC
### Modes
1. **Idle** — self-balancing in place, waiting for command
2. **RC** — manual control via ELRS radio (primary testing mode)
3. **Follow** — tracks person with RealSense, follows at set distance
4. **Explore** — autonomous SLAM mapping, builds house map
5. **Patrol** — follows waypoints on saved map
6. **Dock** — returns to charging station (future)
**Mode priority:** RC override always wins. If radio sends stick input, it overrides Jetson commands. Kill switch overrides everything.
## Build Order
### Phase 1: Balance (Week 1)
**Safety first — no motor spins without kill switch + tether in place.**
- [ ] Install hardware kill switch inline with 36V battery (NC — press to kill)
- [ ] Set up ceiling tether point above test area (rated for >15kg)
- [ ] Clear test area: 3m radius, no loose items, shoes on
- [ ] Set up PlatformIO project for ESP32-S3 (ESP-IDF)
- [ ] Write QMI8658 SPI driver (read gyro+accel, complementary filter)
- [ ] Write PID balance loop with ALL safety checks:
- ±25° tilt cutoff → disarm, require manual re-arm
- Watchdog timer (50ms hardware WDT)
- Speed limit at 10% (max_speed_limit = 100)
- Arming sequence (3s hold while upright)
- [ ] Write hoverboard ESC UART output (speed+steer protocol)
- [ ] Flash firmware via USB DFU (boot0 jumper on FC)
- [ ] Write ELRS CRSF receiver driver (UART3, parse channels + arm switch)
- [ ] Bind ELRS TX ↔ RX, verify channel data on serial monitor
- [ ] Map radio: CH1=steer, CH2=speed, CH5=arm/disarm switch
- [ ] **Bench test first** — FC powered but ESC disconnected, verify IMU reads + PID output + RC channels on serial monitor
- [ ] Wire FC UART2 → hoverboard ESC UART
- [ ] Build minimal frame: motor plate + battery + ESC + FC
- [ ] Power FC from ESC 5V BEC
- [ ] **First balance test — TETHERED, kill switch in hand, 10% speed limit**
- [ ] Tune PID at 10% speed until stable tethered for 5+ minutes
- [ ] Gradually increase speed limit (10% increments, 5 min stable each)
### Phase 2: Brain (Week 2)
- [ ] Mount Jetson + power (DC-DC 5V)
- [ ] Set up JetPack + ROS2
- [ ] Add Jetson UART RX to FC firmware (receive speed+steer commands)
- [ ] Wire Jetson UART1 → FC UART1
- [ ] Python serial bridge: send speed+steer, read telemetry
- [ ] Test: keyboard teleoperation while balancing
### Phase 3: Senses (Week 3)
- [ ] Mount RealSense + RPLIDAR
- [ ] SLAM mapping of a room
- [ ] Person detection + tracking (SSD-MobileNet-v2 via TensorRT)
- [ ] Follow mode: maintain 1.5m distance from person
### Phase 4: Polish (Week 4)
- [ ] Print proper enclosures, bumpers, diffuser ring
- [ ] Wire ESP32-C3 to FC telemetry TX line (listen-only tap)
- [ ] Flash ESP32-C3: parse telemetry, drive WS2812B via RMT
- [ ] Mount LED strip around frame with diffuser
- [ ] Test all LED patterns: disarmed/arming/armed/turning/fault
- [ ] Speaker for audio feedback
- [ ] WiFi status dashboard (ESP32-C3 can serve this too)
- [ ] Emergency stop button

View File

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

284
docs/board-viz.html Normal file
View File

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GEPRC GEP-F722-45A AIO — Board Layout (Legacy / Archived)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #eee; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; padding: 20px; }
h1 { color: #e94560; margin-bottom: 5px; font-size: 1.4em; }
.subtitle { color: #888; margin-bottom: 20px; font-size: 0.85em; }
.container { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; justify-content: center; }
.board-wrap { position: relative; }
.board { width: 400px; height: 340px; background: #1a472a; border: 3px solid #333; border-radius: 8px; position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.5); }
.board::before { content: 'GEPRC GEP-F722-45A AIO'; position: absolute; top: 8px; left: 50%; transform: translateX(-50%); color: #fff3; font-size: 10px; letter-spacing: 2px; }
/* Mounting holes */
.mount { width: 10px; height: 10px; background: #111; border: 2px solid #555; border-radius: 50%; position: absolute; }
.mount.tl { top: 15px; left: 15px; }
.mount.tr { top: 15px; right: 15px; }
.mount.bl { bottom: 15px; left: 15px; }
.mount.br { bottom: 15px; right: 15px; }
/* MCU */
.mcu { width: 80px; height: 80px; background: #222; border: 1px solid #555; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; font-size: 9px; color: #aaa; text-align: center; line-height: 1.3; }
.mcu .dot { width: 5px; height: 5px; background: #666; border-radius: 50%; position: absolute; top: 4px; left: 4px; }
/* IMU */
.imu { width: 32px; height: 32px; background: #333; border: 1px solid #e94560; position: absolute; top: 85px; left: 60px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: #e94560; }
.imu::after { content: 'CW90°'; position: absolute; bottom: -14px; color: #e94560; font-size: 8px; white-space: nowrap; }
/* Arrow showing CW90 rotation */
.rotation-arrow { position: absolute; top: 72px; left: 55px; color: #e94560; font-size: 18px; }
/* Pads */
.pad { position: absolute; display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer; }
.pad .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 7px; font-weight: bold; }
.pad:hover .label { color: #fff; }
.pad .label { transition: color 0.2s; }
.pad .sublabel { font-size: 8px; color: #888; }
/* UART colors */
.uart1 .dot { background: #2196F3; border-color: #64B5F6; }
.uart2 .dot { background: #FF9800; border-color: #FFB74D; }
.uart3 .dot { background: #9C27B0; border-color: #CE93D8; }
.uart4 .dot { background: #4CAF50; border-color: #81C784; }
.uart5 .dot { background: #F44336; border-color: #EF9A9A; }
/* Component dots */
.comp { position: absolute; font-size: 9px; display: flex; align-items: center; gap: 4px; }
.comp .icon { width: 10px; height: 10px; border-radius: 2px; }
/* LED */
.led-blue { position: absolute; width: 8px; height: 8px; background: #2196F3; border-radius: 50%; box-shadow: 0 0 8px #2196F3; top: 45px; right: 50px; }
.led-label { position: absolute; top: 36px; right: 30px; font-size: 8px; color: #64B5F6; }
/* Boot button */
.boot-btn { position: absolute; width: 16px; height: 10px; background: #b8860b; border: 1px solid #daa520; border-radius: 2px; bottom: 45px; right: 40px; }
.boot-label { position: absolute; bottom: 32px; right: 30px; font-size: 8px; color: #daa520; }
/* USB */
.usb { position: absolute; width: 30px; height: 14px; background: #444; border: 2px solid #777; border-radius: 3px; bottom: -3px; left: 50%; transform: translateX(-50%); }
.usb-label { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); font-size: 8px; color: #999; }
/* Connector pads along edges */
/* Bottom row: T1 R1 T3 R3 */
.pad-t1 { bottom: 20px; left: 40px; }
.pad-r1 { bottom: 20px; left: 80px; }
.pad-t3 { bottom: 20px; left: 140px; }
.pad-r3 { bottom: 20px; left: 180px; }
/* Right side: T2 R2 */
.pad-t2 { right: 20px; top: 80px; flex-direction: row-reverse; }
.pad-r2 { right: 20px; top: 110px; flex-direction: row-reverse; }
/* Top row: T4 R4 T5 R5 */
.pad-t4 { top: 30px; left: 40px; }
.pad-r4 { top: 30px; left: 80px; }
.pad-t5 { top: 30px; right: 100px; flex-direction: row-reverse; }
.pad-r5 { top: 30px; right: 55px; flex-direction: row-reverse; }
/* ESC pads (motor outputs - not used) */
.esc-pads { position: absolute; left: 20px; top: 140px; }
.esc-pads .esc-label { font-size: 8px; color: #555; }
/* Legend */
.legend { background: #16213e; padding: 15px 20px; border-radius: 8px; min-width: 280px; }
.legend h2 { color: #e94560; font-size: 1.1em; margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 5px; }
.legend-item { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; }
.legend-item .swatch { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
.legend-item .arrow { color: #888; font-size: 10px; }
.legend-section { margin-top: 12px; padding-top: 8px; border-top: 1px solid #333; }
.legend-section h3 { font-size: 0.9em; color: #888; margin-bottom: 6px; }
/* Orientation guide */
.orient { margin-top: 20px; background: #16213e; padding: 15px 20px; border-radius: 8px; width: 100%; max-width: 710px; }
.orient h2 { color: #4CAF50; font-size: 1.1em; margin-bottom: 10px; }
.orient-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.orient-item { font-size: 12px; padding: 6px 10px; background: #1a1a2e; border-radius: 4px; }
.orient-item .dir { color: #4CAF50; font-weight: bold; }
/* Axis overlay */
.axis { position: absolute; }
.axis-x { top: 50%; right: -60px; color: #F44336; font-size: 12px; font-weight: bold; }
.axis-y { bottom: -30px; left: 50%; transform: translateX(-50%); color: #4CAF50; font-size: 12px; font-weight: bold; }
.axis-arrow-x { position: absolute; top: 50%; right: -45px; transform: translateY(-50%); width: 30px; height: 2px; background: #F44336; }
.axis-arrow-x::after { content: '▶'; position: absolute; right: -12px; top: -8px; color: #F44336; }
.axis-arrow-y { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); width: 2px; height: 20px; background: #4CAF50; }
.axis-arrow-y::after { content: '▼'; position: absolute; bottom: -14px; left: -5px; color: #4CAF50; }
.note { margin-top: 15px; color: #888; font-size: 11px; text-align: center; max-width: 710px; }
.note em { color: #e94560; font-style: normal; }
</style>
</head>
<body>
<<<<<<< HEAD
<h1>🤖 GEPRC GEP-F722-45A AIO — SaltyLab Pinout (Legacy / Archived)</h1>
<p class="subtitle">ESP32RET6 + ICM-42688-P | Betaflight target: GEPR-GEPRC_F722_AIO</p>
=======
<h1>🤖 GEPRC GEP-F722-45A AIO — SaltyLab Pinout</h1>
<p class="subtitle">ESP32-S3RET6 + ICM-42688-P | Betaflight target: GEPR-GEPRC_F722_AIO</p>
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
<div class="container">
<div class="board-wrap">
<div class="board">
<!-- Mounting holes -->
<div class="mount tl"></div>
<div class="mount tr"></div>
<div class="mount bl"></div>
<div class="mount br"></div>
<!-- MCU -->
<<<<<<< HEAD
<div class="mcu"><div class="dot"></div>ESP32<br>(legacy:<br>F722RET6)</div>
=======
<div class="mcu"><div class="dot"></div>ESP32-S3<br>F722RET6<br>216MHz</div>
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
<!-- IMU -->
<div class="imu">ICM<br>42688</div>
<div class="rotation-arrow"></div>
<!-- LED -->
<div class="led-blue"></div>
<div class="led-label">LED PC4</div>
<!-- Boot button -->
<div class="boot-btn"></div>
<div class="boot-label">BOOT 🟡</div>
<!-- USB -->
<div class="usb"></div>
<div class="usb-label">USB-C (DFU)</div>
<!-- UART Pads - Bottom -->
<div class="pad pad-t1 uart1">
<div class="dot">T</div>
<span class="label">T1<br><span class="sublabel">PA9</span></span>
</div>
<div class="pad pad-r1 uart1">
<div class="dot">R</div>
<span class="label">R1<br><span class="sublabel">PA10</span></span>
</div>
<div class="pad pad-t3 uart3">
<div class="dot">T</div>
<span class="label">T3<br><span class="sublabel">PB10</span></span>
</div>
<div class="pad pad-r3 uart3">
<div class="dot">R</div>
<span class="label">R3<br><span class="sublabel">PB11</span></span>
</div>
<!-- UART Pads - Right -->
<div class="pad pad-t2 uart2">
<span class="label">T2<br><span class="sublabel">PA2</span></span>
<div class="dot">T</div>
</div>
<div class="pad pad-r2 uart2">
<span class="label">R2<br><span class="sublabel">PA3</span></span>
<div class="dot">R</div>
</div>
<!-- UART Pads - Top -->
<div class="pad pad-t4 uart4">
<div class="dot">T</div>
<span class="label">T4<br><span class="sublabel">PC10</span></span>
</div>
<div class="pad pad-r4 uart4">
<div class="dot">R</div>
<span class="label">R4<br><span class="sublabel">PC11</span></span>
</div>
<div class="pad pad-t5 uart5">
<span class="label">T5<br><span class="sublabel">PC12</span></span>
<div class="dot">T</div>
</div>
<div class="pad pad-r5 uart5">
<span class="label">R5<br><span class="sublabel">PD2</span></span>
<div class="dot">R</div>
</div>
<!-- ESC motor pads label -->
<div class="esc-pads">
<div class="esc-label">M1-M4 (unused)<br>PC6-PC9</div>
</div>
<!-- Board axes -->
<div class="axis-arrow-x"></div>
<div class="axis axis-x">X →<br><span style="font-size:9px;color:#888">board right</span></div>
<div class="axis-arrow-y"></div>
<div class="axis axis-y">Y ↓ (board forward = tilt axis)</div>
</div>
</div>
<div class="legend">
<h2>🔌 UART Assignments</h2>
<div class="legend-item">
<div class="swatch" style="background:#2196F3"></div>
<span><b>USART1</b> T1/R1 → Jetson Orin Nano Super</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#FF9800"></div>
<span><b>USART2</b> T2 → Hoverboard ESC (TX only)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#9C27B0"></div>
<span><b>I2C2</b> T3/R3 → Baro/Mag (reserved)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#4CAF50"></div>
<span><b>UART4</b> T4/R4 → ELRS RX (CRSF)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#F44336"></div>
<span><b>UART5</b> T5/R5 → Debug/spare</span>
</div>
<div class="legend-section">
<h3>📡 SPI Bus</h3>
<div class="legend-item">
<span>SPI1: PA5/PA6/PA7 → IMU (CS: <em style="color:#e94560">PA15</em>)</span>
</div>
<div class="legend-item">
<span>SPI2: PB13-15 → OSD MAX7456</span>
</div>
<div class="legend-item">
<span>SPI3: PB3-5 → Flash W25Q128</span>
</div>
</div>
<div class="legend-section">
<h3>⚡ Other</h3>
<div class="legend-item">
<span>🔵 LED: PC4 | 📢 Beeper: PC15</span>
</div>
<div class="legend-item">
<span>🔋 VBAT: PC2 | ⚡ Current: PC1</span>
</div>
<div class="legend-item">
<span>💡 LED Strip: PA1 (WS2812)</span>
</div>
<div class="legend-item">
<span>📍 EXTI (IMU data-ready): PA8</span>
</div>
</div>
</div>
</div>
<div class="orient">
<h2>🧭 IMU Orientation (CW90° from chip to board)</h2>
<div class="orient-grid">
<div class="orient-item"><span class="dir">Board Forward</span> (tilt for balance) = Chip's +Y axis</div>
<div class="orient-item"><span class="dir">Board Right</span> = Chip's -X axis</div>
<div class="orient-item"><span class="dir">Board Pitch Rate</span> = -Gyro X (raw)</div>
<div class="orient-item"><span class="dir">Board Accel Forward</span> = Accel Y (raw)</div>
</div>
</div>
<p class="note">
⚠️ Pad positions are <em>approximate</em> — check the physical board silkscreen for exact locations.
The CW90 rotation is handled in firmware (mpu6000.c). USB-C at bottom edge for DFU flashing.
</p>
</body>
</html>

View File

@ -1,131 +1,155 @@
# SaltyLab Wiring Diagram # SaltyLab / SAUL-TEE Wiring Reference
## System Overview > ⚠️ **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)
> Historical STM32/Mamba wiring below is **obsolete** — retained for reference only.
---
## ~~System Overview~~ (OBSOLETE — see SAUL-TEE-SYSTEM-REFERENCE.md)
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ ORIN NANO SUPER │ │ ORIN NANO SUPER │
│ (Top Plate — 25W) │ │ (Top Plate — 25W) │
│ │ │ │
│ USB-C ──── STM32 CDC (/dev/stm32-bridge, 921600 baud) │ <<<<<<< HEAD
│ 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-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) │
│ CSI-A ──── ArduCam adapter → 2× IMX219 (front + left) │ │ CSI-A ──── ArduCam adapter → 2x IMX219 (front + left) │
│ CSI-B ──── ArduCam adapter → 2× IMX219 (rear + right) │ │ CSI-B ──── ArduCam adapter → 2x IMX219 (rear + right) │
│ M.2 ───── 1TB NVMe SSD │ │ M.2 ───── 1TB NVMe SSD │
│ 40-pin ─── ReSpeaker 2-Mic HAT (I2S + I2C, WM8960 codec) │ │ 40-pin ─── ReSpeaker 2-Mic HAT (I2S + I2C, WM8960 codec) │
│ Pin 8 ──┐ │ │ Pin 8 ──┐ │
│ Pin 10 ─┤ UART fallback to FC (ttyTHS0, 921600) │ Pin 10 ─┤ UART fallback to ESP32-S3 BALANCE (ttyTHS0, 460800)
│ Pin 6 ──┘ GND │ │ Pin 6 ──┘ GND │
│ │ │ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
│ USB-C (data only) │ UART fallback (3 wires) │ USB-A (CANable2) │ UART fallback (3 wires)
│ 921600 baud │ 921600 baud, 3.3V │ SocketCAN slcan0 │ 460800 baud, 3.3V
│ 500 kbps │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ MAMBA F722S (FC) │ <<<<<<< HEAD
│ ESP32-S3 BALANCE │
│ (Waveshare Touch LCD 1.28, Middle Plate) │
=======
│ ESP32-S3 BALANCE (FC) │
│ (Middle Plate — foam mounted) │ │ (Middle Plate — foam mounted) │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │ │ │
│ USB-C ──── Orin (CDC serial, primary link) │ │ CAN bus ──── CANable2 → Orin (primary link, ISO 11898) │
│ │ │ UART0 ──── Orin UART fallback (460800 baud, 3.3V) │
│ USART2 (PA2=TX, PA3=RX) ──── Hoverboard ESC (26400 baud) │ │ UART1 ──── VESC Left (CAN ID 56) via UART/CAN bridge │
│ UART4 (PA0=TX, PA1=RX) ──── ELRS RX (CRSF, 420000 baud) │ │ UART2 ──── VESC Right (CAN ID 68) via UART/CAN bridge │
│ USART6 (PC6=TX, PC7=RX) ──── Orin UART fallback │ │ I2C ──── QMI8658 IMU (onboard, 6-DOF accel+gyro) │
│ UART5 (PC12=TX, PD2=RX) ─── Debug (optional) │ │ SPI ──── GC9A01 LCD (onboard, 240x240 round display) │
│ │ │ GPIO ──── WS2812B LED strip │
│ SPI1 ─── MPU6000 IMU (on-board, CW270) │ │ GPIO ──── Buzzer │
│ I2C1 ─── BMP280 baro (on-board, disabled) │ │ ADC ──── Battery voltage divider │
│ ADC ──── Battery voltage (PC1) + Current (PC3) │
│ PB3 ──── WS2812B LED strip │
│ PB2 ──── Buzzer │
│ │ │ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
│ USART2 │ UART4 │ CAN bus (ISO 11898) │ UART (460800 baud)
│ PA2=TX → ESC RX │ PA0=TX → ELRS TX │ 500 kbps │
│ PA3=RX ← ESC TX │ PA1=RX ← ELRS RX
│ GND ─── GND │ GND ─── GND
▼ ▼ ▼ ▼
┌────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ ┌──────────────────────────┐
│ HOVERBOARD ESC │ │ ELRS 2.4GHz RX │ │ VESC Left (ID 56) │ │ VESC Right (ID 68) │
│ (Bottom Plate) │ │ (beside FC) │ │ (Bottom Plate) │ │ (Bottom Plate) │
│ │ │ │
│ BLDC hub motor │ │ BLDC hub motor │
│ CAN 500 kbps │ │ CAN 500 kbps │
│ FOC current control │ │ FOC current control │
│ VESC Status 1 (0x900) │ │ VESC Status 1 (0x910) │
│ │ │ │ │ │ │ │
│ 2× BLDC hub motors │ │ CRSF protocol │
│ 26400 baud UART │ │ 420000 baud │
│ Frame: [0xABCD] │ │ BetaFPV 1W TX → RX │
│ [steer][speed][csum] │ │ CH3=speed CH4=steer │
│ │ │ CH5=arm CH6=mode │
└────────────────────────┘ └──────────────────────────┘ └────────────────────────┘ └──────────────────────────┘
│ │
┌────┴────┐ LEFT MOTOR RIGHT MOTOR
▼ ▼ ```
🛞 LEFT RIGHT 🛞
MOTOR MOTOR
## Wire-by-Wire Connections ## Wire-by-Wire Connections
### 1. Orin ↔ FC (Primary: USB CDC) <<<<<<< HEAD
### 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 Color | Notes | | From | To | Wire | Notes |
|------|----|-----------|-------| |------|----|------|-------|
| Orin USB-C port | FC USB-C port | USB cable | Data only, FC powered from 5V bus | | Orin USB-A | CANable2 USB | USB cable | SocketCAN slcan0 @ 500 kbps |
| CANable2 CAN-H | ESP32-S3 BALANCE CAN-H | twisted pair | ISO 11898 differential |
| CANable2 CAN-L | ESP32-S3 BALANCE CAN-L | twisted pair | ISO 11898 differential |
- Device: `/dev/ttyACM0` → symlink `/dev/stm32-bridge` <<<<<<< HEAD
- Interface: SocketCAN `slcan0`, 500 kbps
- Device node: `/dev/canable2` (via udev, symlink to ttyUSBx)
- Protocol: CAN frames --- ORIN_CMD_DRIVE (0x300), ORIN_CMD_MODE (0x301), ORIN_CMD_ESTOP (0x302)
- Telemetry: BALANCE_STATUS (0x400), BALANCE_VESC (0x401), BALANCE_IMU (0x402), BALANCE_BATTERY (0x403)
=======
- Device: `/dev/ttyACM0` → symlink `/dev/esp32-bridge`
- Baud: 921600, 8N1 - 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 ↔ FC (Fallback: Hardware UART) ### 2. Orin <-> ESP32-S3 BALANCE (Fallback: Hardware UART)
| Orin Pin | Signal | FC Pin | FC Signal | | Orin Pin | Signal | ESP32-S3 Pin | Notes |
|----------|--------|--------|-----------| |----------|--------|--------------|-------|
| Pin 8 | TXD0 | PC7 | USART6 RX | | Pin 8 | TXD0 | GPIO17 (UART0 RX) | Orin TX -> BALANCE RX |
| Pin 10 | RXD0 | PC6 | USART6 TX | | Pin 10 | RXD0 | GPIO18 (UART0 TX) | Orin RX <- BALANCE TX |
| Pin 6 | GND | GND | GND | | Pin 6 | GND | GND | Common ground |
- Jetson device: `/dev/ttyTHS0` - Jetson device: `/dev/ttyTHS0`
- Baud: 921600, 8N1 - Baud: 460800, 8N1
- Voltage: 3.3V both sides (no level shifter needed) - Voltage: 3.3V both sides (no level shifter needed)
- **Cross-connect:** Orin TX → FC RX, Orin RX ← FC TX - Cross-connect: Orin TX -> BALANCE RX, Orin RX <- BALANCE TX
### 3. FC ↔ Hoverboard ESC ### 3. Orin <-> ESP32-S3 IO (USB Serial)
| FC Pin | Signal | ESC Pin | Notes | | From | To | Notes |
|--------|--------|---------|-------| |------|----|-------|
| PA2 | USART2 TX | RX | FC sends speed/steer commands | | Orin USB-A | ESP32-S3 IO USB-C | USB cable, /dev/esp32-io |
| PA3 | USART2 RX | TX | ESC sends feedback (optional) |
- Device node: `/dev/esp32-io` (udev symlink)
- Baud: 460800, 8N1
- Protocol: Binary frames `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
- Use: IO expansion, GPIO control, sensor polling
### 4. ESP32-S3 BALANCE <-> VESC Motors (CAN Bus)
| BALANCE Pin | Signal | VESC Pin | Notes |
|-------------|--------|----------|-------|
| GPIO21 | CAN-H | CAN-H | ISO 11898 differential pair |
| GPIO22 | CAN-L | CAN-L | ISO 11898 differential pair |
| GND | GND | GND | Common ground | | GND | GND | GND | Common ground |
- Baud: 26400, 8N1 - Baud: 500 kbps CAN
- Protocol: Binary frame — `[0xABCD][steer:int16][speed:int16][checksum:uint16]` - VESC Left: CAN ID 56, VESC Right: CAN ID 68
- Speed range: -1000 to +1000 - Commands: COMM_SET_RPM, COMM_SET_CURRENT, COMM_SET_DUTY
- **Keep wires short and twisted** (EMI from ESC) - Telemetry: VESC Status 1 at 50 Hz (RPM, current, duty)
### 4. FC ↔ ELRS Receiver
| FC Pin | Signal | ELRS Pin | Notes |
|--------|--------|----------|-------|
| PA0 | UART4 TX | RX | Telemetry to TX (optional) |
| PA1 | UART4 RX | TX | CRSF frames from RX |
| GND | GND | GND | Common ground |
| 5V | — | VCC | Power ELRS from 5V bus |
- Baud: 420000 (CRSF protocol)
- Failsafe: disarm after 300ms without frame
### 5. Power Distribution ### 5. Power Distribution
``` ```
BATTERY (36V) ──┬── Hoverboard ESC (36V direct) BATTERY (36V) ──┬── VESC Left (36V direct -> BLDC left motor)
├── VESC Right (36V direct -> BLDC right motor)
├── 5V BEC/regulator ──┬── Orin (USB-C PD or barrel jack) ├── 5V BEC/regulator ──┬── Orin (USB-C PD or barrel jack)
│ ├── FC (via USB or 5V pad) │ ├── ESP32-S3 BALANCE (5V via USB-C)
│ ├── ELRS RX (5V) │ ├── ESP32-S3 IO (5V via USB-C)
│ ├── WS2812B LEDs (5V) │ ├── WS2812B LEDs (5V)
│ └── RPLIDAR (5V via USB) │ └── RPLIDAR (5V via USB)
└── Battery monitor ──── FC ADC (PC1=voltage, PC3=current) └── Battery monitor ──── ESP32-S3 BALANCE ADC (voltage divider)
``` ```
### 6. Sensors on Orin (USB/CSI) ### 6. Sensors on Orin (USB/CSI)
@ -136,10 +160,39 @@ BATTERY (36V) ──┬── Hoverboard ESC (36V direct)
| RPLIDAR A1M8 | USB-UART | USB-A | `/dev/rplidar` | | RPLIDAR A1M8 | USB-UART | USB-A | `/dev/rplidar` |
| IMX219 front+left | MIPI CSI-2 | CSI-A (J5) | `/dev/video0,2` | | IMX219 front+left | MIPI CSI-2 | CSI-A (J5) | `/dev/video0,2` |
| IMX219 rear+right | MIPI CSI-2 | CSI-B (J8) | `/dev/video4,6` | | IMX219 rear+right | MIPI CSI-2 | CSI-B (J8) | `/dev/video4,6` |
| 1TB NVMe | PCIe Gen3 ×4 | M.2 Key M | `/dev/nvme0n1` | | 1TB NVMe | PCIe Gen3 x4 | M.2 Key M | `/dev/nvme0n1` |
| CANable2 | USB-CAN | USB-A | `/dev/canable2` -> `slcan0` |
## FC UART Summary (MAMBA F722S) <<<<<<< HEAD
## 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 | | UART | Pins | Baud | Assignment | Notes |
|------|------|------|------------|-------| |------|------|------|------------|-------|
@ -149,7 +202,8 @@ BATTERY (36V) ──┬── Hoverboard ESC (36V direct)
| UART4 | PA0=TX, PA1=RX | 420000 | ELRS RX (CRSF) | RC control | | UART4 | PA0=TX, PA1=RX | 420000 | ELRS RX (CRSF) | RC control |
| UART5 | PC12=TX, PD2=RX | 115200 | Debug serial | Optional | | UART5 | PC12=TX, PD2=RX | 115200 | Debug serial | Optional |
| USART6 | PC6=TX, PC7=RX | 921600 | Jetson UART | Fallback link | | USART6 | PC6=TX, PC7=RX | 921600 | Jetson UART | Fallback link |
| USB CDC | USB-C | 921600 | Jetson primary | `/dev/stm32-bridge` | | USB Serial (CH343) | USB-C | 921600 | Jetson primary | `/dev/esp32-bridge` |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
### 7. ReSpeaker 2-Mic HAT (on Orin 40-pin header) ### 7. ReSpeaker 2-Mic HAT (on Orin 40-pin header)
@ -167,57 +221,63 @@ BATTERY (36V) ──┬── Hoverboard ESC (36V direct)
| Pin 2, 4 | 5V | Power | | Pin 2, 4 | 5V | Power |
| Pin 6, 9 | GND | Ground | | Pin 6, 9 | GND | Ground |
- **Codec:** Wolfson WM8960 (I2C addr 0x1A) - Codec: Wolfson WM8960 (I2C addr 0x1A)
- **Mics:** 2× MEMS (left + right) — basic stereo / sound localization - Mics: 2x MEMS (left + right) --- basic stereo / sound localization
- **Speaker:** 3W class-D amp output (JST connector) - Speaker: 3W class-D amp output (JST connector)
- **Headset:** 3.5mm TRRS jack - Headset: 3.5mm TRRS jack
- **Requires:** WM8960 device tree overlay for Jetson (community port) - Requires: WM8960 device tree overlay for Jetson (community port)
- **Use:** Voice commands (faster-whisper), wake word (openWakeWord), audio feedback, status announcements - Use: Voice commands (faster-whisper), wake word (openWakeWord), audio feedback, status announcements
### 8. SIM7600A 4G/LTE HAT (via USB) ### 8. SIM7600A 4G/LTE HAT (via USB)
| Connection | Detail | | Connection | Detail |
|-----------|--------| |-----------|--------|
| Interface | USB (micro-B on HAT USB-A/C on Orin) | | Interface | USB (micro-B on HAT -> USB-A/C on Orin) |
| Device nodes | `/dev/ttyUSB0` (AT), `/dev/ttyUSB1` (PPP/data), `/dev/ttyUSB2` (GPS NMEA) | | Device nodes | `/dev/ttyUSB0` (AT), `/dev/ttyUSB1` (PPP/data), `/dev/ttyUSB2` (GPS NMEA) |
| Power | 5V from USB or separate 5V supply (peak 2A during TX) | | Power | 5V from USB or separate 5V supply (peak 2A during TX) |
| SIM | Nano-SIM slot on HAT | | SIM | Nano-SIM slot on HAT |
| Antenna | 4G LTE + GPS/GNSS (external SMA antennas mount high on chassis) | | Antenna | 4G LTE + GPS/GNSS (external SMA antennas --- mount high on chassis) |
- **Data:** PPP or QMI for internet connectivity - Data: PPP or QMI for internet connectivity
- **GPS/GNSS:** Built-in receiver, NMEA sentences on ttyUSB2 — outdoor positioning - GPS/GNSS: Built-in receiver, NMEA sentences on ttyUSB2 --- outdoor positioning
- **AT commands:** `AT+CGPS=1` (enable GPS), `AT+CGPSINFO` (get fix) - AT commands: `AT+CGPS=1` (enable GPS), `AT+CGPSINFO` (get fix)
- **Connected via USB** (not 40-pin) — avoids UART conflict with FC fallback, flexible antenna placement - Connected via USB (not 40-pin) --- avoids UART conflict with BALANCE fallback, flexible antenna placement
- **Use:** Remote telemetry, 4G connectivity outdoors, GPS positioning, remote SSH/control - Use: Remote telemetry, 4G connectivity outdoors, GPS positioning, remote SSH/control
### 10. Leap Motion Controller (USB) ### 9. Leap Motion Controller (USB)
| Connection | Detail | | Connection | Detail |
|-----------|--------| |-----------|--------|
| Interface | USB 3.0 (micro-B on controller USB-A on Orin) | | Interface | USB 3.0 (micro-B on controller -> USB-A on Orin) |
| Power | ~0.5W | | Power | ~0.5W |
| Range | ~80cm, 150° FOV | | Range | ~80cm, 150 deg FOV |
| SDK | Ultraleap Gemini V5+ (Linux ARM64 support) | | SDK | Ultraleap Gemini V5+ (Linux ARM64 support) |
| ROS2 | `leap_motion_ros2` wrapper available | | ROS2 | `leap_motion_ros2` wrapper available |
- **2× IR cameras + 3× IR LEDs** tracks all 10 fingers in 3D, sub-mm precision - 2x IR cameras + 3x IR LEDs --- tracks all 10 fingers in 3D, sub-mm precision
- **Mount:** Forward-facing on sensor tower or upward on Orin plate - Mount: Forward-facing on sensor tower or upward on Orin plate
- **Use:** Gesture control (palm=stop, point=go, fist=arm), hand-following mode, demos - Use: Gesture control (palm=stop, point=go, fist=arm), hand-following mode, demos
- **Combined with ReSpeaker:** Voice + gesture control with zero hardware in hand - Combined with ReSpeaker: Voice + gesture control with zero hardware in hand
### 11. Power Budget (USB) ### 10. Power Budget (USB)
| Device | Interface | Power Draw | | Device | Interface | Power Draw |
|--------|-----------|------------| |--------|-----------|------------|
| STM32 FC (CDC) | USB-C | ~0.5W (data only, FC on 5V bus) | <<<<<<< HEAD
| CANable2 USB-CAN | USB-A | ~0.5W |
| ESP32-S3 BALANCE | USB-C | ~0.8W (WiFi off) |
| ESP32-S3 IO | USB-C | ~0.5W |
=======
| ESP32-S3 FC (CDC) | USB-C | ~0.5W (data only, FC on 5V bus) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| RealSense D435i | USB-A | ~1.5W (3.5W peak) | | 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 | ~0.5W | | Leap Motion | USB-A | ~0.5W |
| ReSpeaker HAT | 40-pin | ~0.5W | | ReSpeaker HAT | 40-pin | ~0.5W |
| **Total USB** | | **~6.5W typical, ~10.5W peak** | | **Total USB** | | **~7.9W typical, ~11W peak** |
Orin Nano Super delivers up to 25W USB peripherals are well within budget. Orin Nano Super delivers up to 25W --- USB peripherals are well within budget.
--- ---
@ -225,38 +285,46 @@ Orin Nano Super delivers up to 25W — USB peripherals are well within budget.
``` ```
┌──────────────┐ ┌──────────────┐
ELRS TX │ (in your hand) RC TX │ (in your hand)
│ (2.4GHz) │ │ (2.4GHz) │
└──────┬───────┘ └──────┬───────┘
│ radio │ radio
┌──────▼───────┐ ┌──────▼───────┐
ELRS RX │ CRSF 420kbaud RC RX │ CRSF 420kbaud (future)
└──────┬───────┘ └──────┬───────┘
│ UART4 │ UART
┌────────────▼────────────┐ ┌────────────▼────────────┐
│ MAMBA F722S │ <<<<<<< HEAD
│ ESP32-S3 BALANCE │
│ (Waveshare LCD 1.28) │
=======
│ ESP32-S3 BALANCE │
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
│ │ │ │
│ MPU6000 → Balance PID │ QMI8658 -> Balance PID
│ CRSF → Mode Manager │ RC -> Mode Manager
│ Safety Monitor │ │ Safety Monitor │
│ │ │ │
└──┬──────────┬───────────┘ └──┬──────────┬───────────┘
USART2 ─────┘ └───── USB CDC / USART6 <<<<<<< HEAD
CAN 500kbps─┘ └───── CAN bus / UART fallback
=======
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) │ ┌───────────────────┐
│ Hoverboard ESC │ │ Orin Nano Super │ ├─ VESC Left 56 │ │ Orin Nano Super │
│ │ │ │ └─ VESC Right 68 │ │ │
│ L motor R motor│ │ SLAM / Nav2 / AI │ │ │ │ SLAM / Nav2 / AI │
│ 🛞 🛞 │ │ Person following │ ▼ ▼ │ Person following │
└────────────────┘ │ Voice commands │ LEFT RIGHT │ Voice commands │
│ 4G telemetry │ MOTOR MOTOR │ 4G telemetry │
└──┬──────────┬───────┘ └──┬──────────┬───────┘
│ │ │ │
┌──────────▼─┐ ┌────▼──────────┐ ┌──────────▼─┐ ┌────▼──────────┐
│ ReSpeaker │ │ SIM7600A │ │ ReSpeaker │ │ SIM7600A │
│ 2-Mic HAT │ │ 4G/LTE + GPS │ │ 2-Mic HAT │ │ 4G/LTE + GPS │
│ 🎤 🔊 │ │ 📡 🛰️ │ └────────────┘ └───────────────┘
└────────────┘ └───────────────┘
``` ```

View File

@ -0,0 +1,30 @@
; SaltyBot UWB Anchor Firmware — Issue #544
; Target: Makerfabs ESP32 UWB Pro (DW3000 chip)
;
; Library: Makerfabs MaUWB_DW3000
; https://github.com/Makerfabs/MaUWB_DW3000
;
; Flash:
; pio run -e anchor0 --target upload (port-side anchor)
; pio run -e anchor1 --target upload (starboard anchor)
; Monitor:
; pio device monitor -e anchor0 -b 115200
[common]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
https://github.com/Makerfabs/MaUWB_DW3000.git
build_flags =
-DCORE_DEBUG_LEVEL=0
[env:anchor0]
extends = common
build_flags = ${common.build_flags} -DANCHOR_ID=0
[env:anchor1]
extends = common
build_flags = ${common.build_flags} -DANCHOR_ID=1

View File

@ -0,0 +1,542 @@
/*
* uwb_anchor SaltyBot ESP32 UWB Pro anchor firmware (TWR responder)
* Issue #544
*
* Hardware: Makerfabs ESP32 UWB Pro (DW3000 chip)
*
* Role
*
* Anchor sits on SaltyBot body, USB-connected to Jetson Orin.
* Two anchors per robot (anchor-0 port side, anchor-1 starboard).
* Person-worn tags initiate ranging; anchors respond.
*
* Protocol: Double-Sided TWR (DS-TWR)
*
* Tag Anchor POLL (msg_type 0x01)
* Anchor Tag RESP (msg_type 0x02, payload: T_poll_rx, T_resp_tx)
* Tag Anchor FINAL (msg_type 0x03, payload: Ra, Da, Db timestamps)
* Anchor computes range via DS-TWR formula, emits +RANGE on Serial.
*
* Serial output (115200 8N1, USB-CDC to Jetson)
*
* +RANGE:<anchor_id>,<range_mm>,<rssi_dbm>\r\n (on each successful range)
*
* AT commands (host anchor)
*
* AT+RANGE? returns last buffered +RANGE line
* AT+RANGE_ADDR=<hex_addr> pair with specific tag (filter others)
* AT+RANGE_ADDR= clear pairing (accept all tags)
* AT+ID? returns +ID:<anchor_id>
* AT+PEER_RANGE=<id> inter-anchor DS-TWR (for auto-calibration)
* +PEER_RANGE:<my>,<peer>,<mm>,<rssi>
*
* Pin mapping Makerfabs ESP32 UWB Pro
*
* SPI SCK 18 SPI MISO 19 SPI MOSI 23
* DW CS 21 DW RST 27 DW IRQ 34
*
* Build
*
* pio run -e anchor0 --target upload (port side)
* pio run -e anchor1 --target upload (starboard)
*/
#include <Arduino.h>
#include <SPI.h>
#include <math.h>
#include "dw3000.h" // Makerfabs MaUWB_DW3000 library
/* ── Configurable ───────────────────────────────────────────────── */
#ifndef ANCHOR_ID
# define ANCHOR_ID 0 /* 0 = port, 1 = starboard */
#endif
#define SERIAL_BAUD 115200
/* ── Pin map (Makerfabs ESP32 UWB Pro) ─────────────────────────── */
#define PIN_SCK 18
#define PIN_MISO 19
#define PIN_MOSI 23
#define PIN_CS 21
#define PIN_RST 27
#define PIN_IRQ 34
/* ── DW3000 channel / PHY config ───────────────────────────────── */
static dwt_config_t dw_cfg = {
5, /* channel 5 (6.5 GHz, best penetration) */
DWT_PLEN_128, /* preamble length */
DWT_PAC8, /* PAC size */
9, /* TX preamble code */
9, /* RX preamble code */
1, /* SFD type (IEEE 802.15.4z) */
DWT_BR_6M8, /* data rate 6.8 Mbps */
DWT_PHR_MODE_STD, /* standard PHR */
DWT_PHR_RATE_DATA,
(129 + 8 - 8), /* SFD timeout */
DWT_STS_MODE_OFF, /* STS off — standard TWR */
DWT_STS_LEN_64,
DWT_PDOA_M0, /* no PDoA */
};
/* ── Frame format ──────────────────────────────────────────────── */
/* Byte layout for all frames:
* [0] frame_type (FTYPE_*)
* [1] src_id (tag 8-bit addr, or ANCHOR_ID)
* [2] dst_id
* [3..] payload
* (FCS appended automatically by DW3000 2 bytes)
*/
#define FTYPE_POLL 0x01
#define FTYPE_RESP 0x02
#define FTYPE_FINAL 0x03
#define FRAME_HDR 3
#define FCS_LEN 2
/* RESP payload: T_poll_rx(5 B) + T_resp_tx(5 B) */
#define RESP_PAYLOAD 10
#define RESP_FRAME_LEN (FRAME_HDR + RESP_PAYLOAD + FCS_LEN)
/* FINAL payload: Ra(5 B) + Da(5 B) + Db(5 B) */
#define FINAL_PAYLOAD 15
#define FINAL_FRAME_LEN (FRAME_HDR + FINAL_PAYLOAD + FCS_LEN)
/* ── Timing ────────────────────────────────────────────────────── */
/* Turnaround delay: anchor waits 500 µs after poll_rx before tx_resp.
* DW3000 tick = 1/(128×499.2e6) 15.65 ps 500 µs = ~31.95M ticks.
* Stored as uint32 shifted right 8 bits for dwt_setdelayedtrxtime. */
#define RESP_TX_DLY_US 500UL
#define DWT_TICKS_PER_US 63898UL /* 1µs in DW3000 ticks (×8 prescaler) */
#define RESP_TX_DLY_TICKS (RESP_TX_DLY_US * DWT_TICKS_PER_US)
/* How long anchor listens for FINAL after sending RESP */
#define FINAL_RX_TIMEOUT_US 3000
/* Speed of light (m/s) */
#define SPEED_OF_LIGHT 299702547.0
/* DW3000 40-bit timestamp mask */
#define DWT_TS_MASK 0xFFFFFFFFFFULL
/* Antenna delay (factory default; calibrate per unit for best accuracy) */
#define ANT_DELAY 16385
/* ── Interrupt flags (set in ISR, polled in main) ──────────────── */
static volatile bool g_rx_ok = false;
static volatile bool g_tx_done = false;
static volatile bool g_rx_err = false;
static volatile bool g_rx_to = false;
static uint8_t g_rx_buf[128];
static uint32_t g_rx_len = 0;
/* ── State ──────────────────────────────────────────────────────── */
/* Last successful range (serves AT+RANGE? queries) */
static int32_t g_last_range_mm = -1;
static char g_last_range_line[72] = {};
/* Optional tag pairing: 0 = accept all tags */
static uint8_t g_paired_tag_id = 0;
/* ── DW3000 ISR callbacks ───────────────────────────────────────── */
static void cb_tx_done(const dwt_cb_data_t *) { g_tx_done = true; }
static void cb_rx_ok(const dwt_cb_data_t *d) {
g_rx_len = d->datalength;
if (g_rx_len > sizeof(g_rx_buf)) g_rx_len = sizeof(g_rx_buf);
dwt_readrxdata(g_rx_buf, g_rx_len, 0);
g_rx_ok = true;
}
static void cb_rx_err(const dwt_cb_data_t *) { g_rx_err = true; }
static void cb_rx_to(const dwt_cb_data_t *) { g_rx_to = true; }
/* ── Timestamp helpers ──────────────────────────────────────────── */
static uint64_t ts_read(const uint8_t *p) {
uint64_t v = 0;
for (int i = 4; i >= 0; i--) v = (v << 8) | p[i];
return v;
}
static void ts_write(uint8_t *p, uint64_t v) {
for (int i = 0; i < 5; i++, v >>= 8) p[i] = (uint8_t)(v & 0xFF);
}
static inline uint64_t ts_diff(uint64_t later, uint64_t earlier) {
return (later - earlier) & DWT_TS_MASK;
}
static inline double ticks_to_s(uint64_t t) {
return (double)t / (128.0 * 499200000.0);
}
/* Estimate receive power from CIR diagnostics (dBm) */
static float rx_power_dbm(void) {
dwt_rxdiag_t d;
dwt_readdiagnostics(&d);
if (d.maxGrowthCIR == 0 || d.rxPreamCount == 0) return 0.0f;
float f = (float)d.maxGrowthCIR;
float n = (float)d.rxPreamCount;
return 10.0f * log10f((f * f) / (n * n)) - 121.74f;
}
/* ── Peer-anchor ranging (initiator role, for auto-calibration) ─── */
/* Timeout waiting for peer's RESP during inter-anchor ranging */
#define PEER_RX_RESP_TIMEOUT_US 3500
/* Block up to this many ms waiting for the interrupt flags */
#define PEER_WAIT_MS 20
/* Initiate a single DS-TWR exchange toward peer anchor `peer_id`.
* Returns range in mm (>=0) on success, or -1 on timeout/error.
* RSSI is stored in *rssi_out if non-null.
*
* Exchange:
* This anchor peer POLL
* peer This RESP (carries T_poll_rx, T_resp_tx)
* This anchor peer FINAL (carries Ra, Da)
* This side computes its own range estimate from Ra/Da.
*/
static int32_t peer_range_once(uint8_t peer_id, float *rssi_out) {
/* ── Reset interrupt flags ── */
g_tx_done = g_rx_ok = g_rx_err = g_rx_to = false;
/* ── Build POLL frame ── */
uint8_t poll_buf[FRAME_HDR + FCS_LEN] = {
FTYPE_POLL,
(uint8_t)ANCHOR_ID,
peer_id,
};
dwt_writetxdata(sizeof(poll_buf), poll_buf, 0);
dwt_writetxfctrl(sizeof(poll_buf), 0, 1);
dwt_setrxtimeout(PEER_RX_RESP_TIMEOUT_US);
dwt_rxenable(DWT_START_RX_IMMEDIATE);
if (dwt_starttx(DWT_START_TX_IMMEDIATE | DWT_RESPONSE_EXPECTED) != DWT_SUCCESS)
return -1;
/* Wait for TX done */
uint32_t t0 = millis();
while (!g_tx_done && !g_rx_err && !g_rx_to) {
if (millis() - t0 > (uint32_t)PEER_WAIT_MS) return -1;
}
if (g_rx_err || g_rx_to) return -1;
g_tx_done = false;
/* Capture T_poll_tx */
uint64_t T_poll_tx;
dwt_readtxtimestamp((uint8_t *)&T_poll_tx);
T_poll_tx &= DWT_TS_MASK;
/* Wait for RESP */
t0 = millis();
while (!g_rx_ok && !g_rx_err && !g_rx_to) {
if (millis() - t0 > (uint32_t)PEER_WAIT_MS) return -1;
}
if (!g_rx_ok) return -1;
/* Validate RESP */
if (g_rx_len < (uint32_t)(FRAME_HDR + RESP_PAYLOAD + FCS_LEN)) return -1;
if (g_rx_buf[0] != FTYPE_RESP) return -1;
if (g_rx_buf[1] != peer_id) return -1;
if (g_rx_buf[2] != (uint8_t)ANCHOR_ID) return -1;
uint64_t T_resp_rx;
dwt_readrxtimestamp((uint8_t *)&T_resp_rx);
T_resp_rx &= DWT_TS_MASK;
/* Extract peer timestamps from RESP payload */
const uint8_t *pl = g_rx_buf + FRAME_HDR;
uint64_t T_poll_rx_peer = ts_read(pl);
uint64_t T_resp_tx_peer = ts_read(pl + 5);
/* DS-TWR Ra, Da */
uint64_t Ra = ts_diff(T_resp_rx, T_poll_tx);
uint64_t Da = ts_diff(T_resp_tx_peer, T_poll_rx_peer);
g_rx_ok = g_rx_err = g_rx_to = false;
/* ── Build FINAL frame ── */
uint8_t final_buf[FRAME_HDR + FINAL_PAYLOAD + FCS_LEN];
final_buf[0] = FTYPE_FINAL;
final_buf[1] = (uint8_t)ANCHOR_ID;
final_buf[2] = peer_id;
ts_write(final_buf + FRAME_HDR, Ra);
ts_write(final_buf + FRAME_HDR + 5, Da);
ts_write(final_buf + FRAME_HDR + 10, (uint64_t)0); /* Db placeholder */
dwt_setrxtimeout(0);
dwt_writetxdata(sizeof(final_buf), final_buf, 0);
dwt_writetxfctrl(sizeof(final_buf), 0, 1);
if (dwt_starttx(DWT_START_TX_IMMEDIATE) != DWT_SUCCESS) return -1;
t0 = millis();
while (!g_tx_done && !g_rx_err) {
if (millis() - t0 > (uint32_t)PEER_WAIT_MS) return -1;
}
g_tx_done = false;
/* Simplified one-sided range estimate: tof = Ra - Da/2 */
double tof_s = ticks_to_s(Ra) - ticks_to_s(Da) / 2.0;
if (tof_s < 0.0) tof_s = 0.0;
int32_t range_mm = (int32_t)(tof_s * SPEED_OF_LIGHT * 1000.0);
if (rssi_out) *rssi_out = rx_power_dbm();
/* Re-enable normal RX for tag ranging */
dwt_setrxtimeout(0);
dwt_rxenable(DWT_START_RX_IMMEDIATE);
return range_mm;
}
/* ── AT command handler ─────────────────────────────────────────── */
static char g_at_buf[64];
static int g_at_idx = 0;
static void at_dispatch(const char *cmd) {
if (strcmp(cmd, "AT+RANGE?") == 0) {
if (g_last_range_mm >= 0)
Serial.println(g_last_range_line);
else
Serial.println("+RANGE:NO_DATA");
} else if (strcmp(cmd, "AT+ID?") == 0) {
Serial.printf("+ID:%d\r\n", ANCHOR_ID);
} else if (strncmp(cmd, "AT+RANGE_ADDR=", 14) == 0) {
const char *v = cmd + 14;
if (*v == '\0') {
g_paired_tag_id = 0;
Serial.println("+OK:UNPAIRED");
} else {
g_paired_tag_id = (uint8_t)strtoul(v, nullptr, 0);
Serial.printf("+OK:PAIRED=0x%02X\r\n", g_paired_tag_id);
}
} else if (strncmp(cmd, "AT+PEER_RANGE=", 14) == 0) {
/* Inter-anchor ranging for calibration.
* Usage: AT+PEER_RANGE=<peer_anchor_id>
* Response: +PEER_RANGE:<my_id>,<peer_id>,<range_mm>,<rssi_dbm>
* or: +PEER_RANGE:ERR,<peer_id>,TIMEOUT
*/
uint8_t peer_id = (uint8_t)strtoul(cmd + 14, nullptr, 0);
float rssi = 0.0f;
int32_t mm = peer_range_once(peer_id, &rssi);
if (mm >= 0) {
Serial.printf("+PEER_RANGE:%d,%d,%ld,%.1f\r\n",
ANCHOR_ID, peer_id, mm, (double)rssi);
} else {
Serial.printf("+PEER_RANGE:ERR,%d,TIMEOUT\r\n", peer_id);
}
} else {
Serial.println("+ERR:UNKNOWN_CMD");
}
}
static void serial_poll(void) {
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\r') continue;
if (c == '\n') {
g_at_buf[g_at_idx] = '\0';
if (g_at_idx > 0) at_dispatch(g_at_buf);
g_at_idx = 0;
} else if (g_at_idx < (int)(sizeof(g_at_buf) - 1)) {
g_at_buf[g_at_idx++] = c;
}
}
}
/* ── DS-TWR anchor state machine ────────────────────────────────── */
/*
* DS-TWR responder (one shot):
* 1. Wait for POLL from tag
* 2. Delayed-TX RESP (carry T_poll_rx + scheduled T_resp_tx)
* 3. Wait for FINAL from tag (tag embeds Ra, Da, Db)
* 4. Compute: Rb = T_final_rx T_resp_tx
* tof = (Ra·Rb Da·Db) / (Ra+Rb+Da+Db)
* range_m = tof × c
* 5. Print +RANGE line
*/
static void twr_cycle(void) {
/* --- 1. Listen for POLL --- */
dwt_setrxtimeout(0);
dwt_rxenable(DWT_START_RX_IMMEDIATE);
g_rx_ok = g_rx_err = false;
uint32_t deadline = millis() + 2000;
while (!g_rx_ok && !g_rx_err) {
serial_poll();
if (millis() > deadline) {
/* restart RX if we've been stuck */
dwt_rxenable(DWT_START_RX_IMMEDIATE);
deadline = millis() + 2000;
}
yield();
}
if (!g_rx_ok || g_rx_len < FRAME_HDR) return;
/* validate POLL */
if (g_rx_buf[0] != FTYPE_POLL) return;
uint8_t tag_id = g_rx_buf[1];
if (g_paired_tag_id != 0 && tag_id != g_paired_tag_id) return;
/* --- 2. Record T_poll_rx --- */
uint8_t poll_rx_raw[5];
dwt_readrxtimestamp(poll_rx_raw);
uint64_t T_poll_rx = ts_read(poll_rx_raw);
/* Compute delayed TX time: poll_rx + turnaround, aligned to 512-tick grid */
uint64_t resp_tx_sched = (T_poll_rx + RESP_TX_DLY_TICKS) & ~0x1FFULL;
/* Build RESP frame */
uint8_t resp[RESP_FRAME_LEN];
resp[0] = FTYPE_RESP;
resp[1] = ANCHOR_ID;
resp[2] = tag_id;
ts_write(&resp[3], T_poll_rx); /* T_poll_rx (tag uses this) */
ts_write(&resp[8], resp_tx_sched); /* scheduled T_resp_tx */
dwt_writetxdata(RESP_FRAME_LEN - FCS_LEN, resp, 0);
dwt_writetxfctrl(RESP_FRAME_LEN, 0, 1 /*ranging*/);
dwt_setdelayedtrxtime((uint32_t)(resp_tx_sched >> 8));
/* Enable RX after TX to receive FINAL */
dwt_setrxaftertxdelay(300);
dwt_setrxtimeout(FINAL_RX_TIMEOUT_US);
/* Fire delayed TX */
g_tx_done = g_rx_ok = g_rx_err = g_rx_to = false;
if (dwt_starttx(DWT_START_TX_DELAYED | DWT_RESPONSE_EXPECTED) != DWT_SUCCESS) {
dwt_forcetrxoff();
return; /* TX window missed — try next cycle */
}
/* Wait for TX done (short wait, ISR fires fast) */
uint32_t t0 = millis();
while (!g_tx_done && millis() - t0 < 15) { yield(); }
/* Read actual T_resp_tx */
uint8_t resp_tx_raw[5];
dwt_readtxtimestamp(resp_tx_raw);
uint64_t T_resp_tx = ts_read(resp_tx_raw);
/* --- 3. Wait for FINAL --- */
t0 = millis();
while (!g_rx_ok && !g_rx_err && !g_rx_to && millis() - t0 < 60) {
serial_poll();
yield();
}
if (!g_rx_ok || g_rx_len < FRAME_HDR + FINAL_PAYLOAD) return;
if (g_rx_buf[0] != FTYPE_FINAL) return;
if (g_rx_buf[1] != tag_id) return;
/* Extract DS-TWR timestamps from FINAL payload */
uint64_t Ra = ts_read(&g_rx_buf[3]); /* tag: T_resp_rx T_poll_tx */
uint64_t Da = ts_read(&g_rx_buf[8]); /* tag: T_final_tx T_resp_rx */
/* g_rx_buf[13..17] = Db from tag (cross-check, unused here) */
/* T_final_rx */
uint8_t final_rx_raw[5];
dwt_readrxtimestamp(final_rx_raw);
uint64_t T_final_rx = ts_read(final_rx_raw);
/* --- 4. DS-TWR formula --- */
uint64_t Rb = ts_diff(T_final_rx, T_resp_tx); /* anchor round-trip */
uint64_t Db = ts_diff(T_resp_tx, T_poll_rx); /* anchor turnaround */
double ra = ticks_to_s(Ra), rb = ticks_to_s(Rb);
double da = ticks_to_s(Da), db = ticks_to_s(Db);
double denom = ra + rb + da + db;
if (denom < 1e-15) return;
double tof = (ra * rb - da * db) / denom;
double range_m = tof * SPEED_OF_LIGHT;
/* Validity window: 0.1 m 130 m */
if (range_m < 0.1 || range_m > 130.0) return;
int32_t range_mm = (int32_t)(range_m * 1000.0 + 0.5);
float rssi = rx_power_dbm();
/* --- 5. Emit +RANGE --- */
snprintf(g_last_range_line, sizeof(g_last_range_line),
"+RANGE:%d,%ld,%.1f", ANCHOR_ID, (long)range_mm, rssi);
g_last_range_mm = range_mm;
Serial.println(g_last_range_line);
}
/* ── Arduino setup ──────────────────────────────────────────────── */
void setup(void) {
Serial.begin(SERIAL_BAUD);
delay(300);
Serial.printf("\r\n[uwb_anchor] anchor_id=%d starting\r\n", ANCHOR_ID);
SPI.begin(PIN_SCK, PIN_MISO, PIN_MOSI, PIN_CS);
/* Hardware reset */
pinMode(PIN_RST, OUTPUT);
digitalWrite(PIN_RST, LOW);
delay(2);
pinMode(PIN_RST, INPUT_PULLUP);
delay(5);
/* DW3000 probe + init (Makerfabs MaUWB_DW3000 library) */
if (dwt_probe((struct dwt_probe_s *)&dw3000_probe_interf)) {
Serial.println("[uwb_anchor] FATAL: DW3000 probe failed — check SPI wiring");
for (;;) delay(1000);
}
if (dwt_initialise(DWT_DW_INIT) != DWT_SUCCESS) {
Serial.println("[uwb_anchor] FATAL: dwt_initialise failed");
for (;;) delay(1000);
}
if (dwt_configure(&dw_cfg) != DWT_SUCCESS) {
Serial.println("[uwb_anchor] FATAL: dwt_configure failed");
for (;;) delay(1000);
}
dwt_setrxantennadelay(ANT_DELAY);
dwt_settxantennadelay(ANT_DELAY);
dwt_settxpower(0x0E080222UL); /* max TX power for 120 m range */
dwt_setcallbacks(cb_tx_done, cb_rx_ok, cb_rx_to, cb_rx_err,
nullptr, nullptr, nullptr);
dwt_setinterrupt(
DWT_INT_TXFRS | DWT_INT_RFCG | DWT_INT_RFTO |
DWT_INT_RFSL | DWT_INT_SFDT | DWT_INT_ARFE | DWT_INT_CPERR,
0, DWT_ENABLE_INT_ONLY);
attachInterrupt(digitalPinToInterrupt(PIN_IRQ),
[]() { dwt_isr(); }, RISING);
Serial.printf("[uwb_anchor] DW3000 ready ch=%d 6.8Mbps id=%d\r\n",
dw_cfg.chan, ANCHOR_ID);
Serial.println("[uwb_anchor] Listening for tags...");
}
/* ── Arduino loop ───────────────────────────────────────────────── */
void loop(void) {
serial_poll();
twr_cycle();
}

View File

@ -0,0 +1,19 @@
# SaltyBot UWB anchor USB-serial persistent symlinks
# Install:
# sudo cp 99-uwb-anchors.rules /etc/udev/rules.d/
# sudo udevadm control --reload && sudo udevadm trigger
#
# Find serial numbers:
# udevadm info -a /dev/ttyUSB0 | grep ATTRS{serial}
#
# Fill ANCHOR0_SERIAL and ANCHOR1_SERIAL with the values found above.
# Anchor 0 = port side → /dev/uwb-anchor0
# Anchor 1 = starboard → /dev/uwb-anchor1
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \
ATTRS{serial}=="ANCHOR0_SERIAL", \
SYMLINK+="uwb-anchor0", MODE="0666"
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \
ATTRS{serial}=="ANCHOR1_SERIAL", \
SYMLINK+="uwb-anchor1", MODE="0666"

View File

@ -0,0 +1,30 @@
; SaltyBot UWB Tag Firmware — Issue #545
; Target: Makerfabs ESP32 UWB Pro with Display (DW3000 + SSD1306 OLED)
;
; The tag is battery-powered, worn by the person being tracked.
; It initiates DS-TWR ranging with each anchor in round-robin,
; shows status on OLED display, and sends data via ESP-NOW.
;
; Library: Makerfabs MaUWB_DW3000
; https://github.com/Makerfabs/MaUWB_DW3000
;
; Flash:
; pio run -e tag --target upload
; Monitor (USB debug):
; pio device monitor -b 115200
[env:tag]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
https://github.com/Makerfabs/MaUWB_DW3000.git
adafruit/Adafruit SSD1306@^2.5.7
adafruit/Adafruit GFX Library@^1.11.5
build_flags =
-DCORE_DEBUG_LEVEL=0
-DTAG_ID=0x01 ; unique per tag (0x010xFE)
-DNUM_ANCHORS=2 ; number of anchors to range with
-DRANGE_INTERVAL_MS=50 ; 20 Hz round-robin across anchors

615
esp32/uwb_tag/src/main.cpp Normal file
View File

@ -0,0 +1,615 @@
/*
* uwb_tag SaltyBot ESP32 UWB Pro tag firmware (DS-TWR initiator)
* Issue #545 + display/ESP-NOW/e-stop extensions
*
* Hardware: Makerfabs ESP32 UWB Pro with Display (DW3000 + SSD1306 OLED)
*
* Role
*
* Tag is worn by a person riding an EUC while SaltyBot follows.
* Initiates DS-TWR ranging with 2 anchors on the robot at 20 Hz.
* Shows distance/status on OLED. Sends range data via ESP-NOW
* (no WiFi AP needed peer-to-peer, ~1ms latency, works outdoors).
* GPIO 0 = emergency stop button (active low).
*
* Serial output (USB, 115200) debug
*
* +RANGE:<anchor_id>,<range_mm>,<rssi_dbm>\r\n
*
* ESP-NOW packet (broadcast, 20 bytes)
*
* [0-1] magic 0x5B 0x01
* [2] tag_id
* [3] msg_type 0x10=range, 0x20=estop, 0x30=heartbeat
* [4] anchor_id
* [5-8] range_mm (int32_t LE)
* [9-12] rssi_dbm (float LE)
* [13-16] timestamp (uint32_t millis)
* [17] battery_pct (0-100 or 0xFF)
* [18] flags bit0=estop_active
* [19] seq_num_lo (uint8_t, rolling)
*
* Pin mapping Makerfabs ESP32 UWB Pro with Display
*
* SPI SCK 18 SPI MISO 19 SPI MOSI 23
* DW CS 21 DW RST 27 DW IRQ 34
* I2C SDA 4 I2C SCL 5 OLED addr 0x3C
* LED 2 E-STOP 0 (BOOT, active LOW)
*/
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <math.h>
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include "dw3000.h"
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
/* ── Configurable ───────────────────────────────────────────────── */
#ifndef TAG_ID
# define TAG_ID 0x01
#endif
#ifndef NUM_ANCHORS
# define NUM_ANCHORS 2
#endif
#ifndef RANGE_INTERVAL_MS
# define RANGE_INTERVAL_MS 50 /* 20 Hz round-robin */
#endif
#define SERIAL_BAUD 115200
/* ── Pins ───────────────────────────────────────────────────────── */
#define PIN_SCK 18
#define PIN_MISO 19
#define PIN_MOSI 23
#define PIN_CS 21
#define PIN_RST 27
#define PIN_IRQ 34
#define PIN_SDA 4
#define PIN_SCL 5
#define PIN_LED 2
#define PIN_ESTOP 0 /* BOOT button, active LOW */
/* ── OLED ───────────────────────────────────────────────────────── */
#define SCREEN_W 128
#define SCREEN_H 64
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);
/* ── ESP-NOW ────────────────────────────────────────────────────── */
#define ESPNOW_MAGIC_0 0x5B /* "SB" */
#define ESPNOW_MAGIC_1 0x01 /* v1 */
#define MSG_RANGE 0x10
#define MSG_ESTOP 0x20
#define MSG_HEARTBEAT 0x30
static uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static uint8_t g_seq = 0;
#pragma pack(push, 1)
struct EspNowPacket {
uint8_t magic[2];
uint8_t tag_id;
uint8_t msg_type;
uint8_t anchor_id;
int32_t range_mm;
float rssi_dbm;
uint32_t timestamp_ms;
uint8_t battery_pct;
uint8_t flags;
uint8_t seq_num;
uint8_t _pad; /* pad to 20 bytes */
};
#pragma pack(pop)
static_assert(sizeof(EspNowPacket) == 20, "packet must be 20 bytes");
/* ── DW3000 PHY config (must match anchor) ──────────────────────── */
static dwt_config_t dw_cfg = {
5, /* channel 5 */
DWT_PLEN_128,
DWT_PAC8,
9, 9, /* TX/RX preamble code */
1, /* SFD type */
DWT_BR_6M8,
DWT_PHR_MODE_STD,
DWT_PHR_RATE_DATA,
(129 + 8 - 8),
DWT_STS_MODE_OFF,
DWT_STS_LEN_64,
DWT_PDOA_M0,
};
/* ── Frame format ──────────────────────────────────────────────── */
#define FTYPE_POLL 0x01
#define FTYPE_RESP 0x02
#define FTYPE_FINAL 0x03
#define FRAME_HDR 3
#define FCS_LEN 2
#define POLL_FRAME_LEN (FRAME_HDR + FCS_LEN)
#define RESP_PAYLOAD 10
#define RESP_FRAME_LEN (FRAME_HDR + RESP_PAYLOAD + FCS_LEN)
#define FINAL_PAYLOAD 15
#define FINAL_FRAME_LEN (FRAME_HDR + FINAL_PAYLOAD + FCS_LEN)
/* ── Timing ────────────────────────────────────────────────────── */
#define FINAL_TX_DLY_US 500UL
#define DWT_TICKS_PER_US 63898UL
#define FINAL_TX_DLY_TICKS (FINAL_TX_DLY_US * DWT_TICKS_PER_US)
#define RESP_RX_TIMEOUT_US 3000
#define SPEED_OF_LIGHT 299702547.0
#define DWT_TS_MASK 0xFFFFFFFFFFULL
#define ANT_DELAY 16385
/* ── ISR state ──────────────────────────────────────────────────── */
static volatile bool g_rx_ok = false;
static volatile bool g_tx_done = false;
static volatile bool g_rx_err = false;
static volatile bool g_rx_to = false;
static uint8_t g_rx_buf[128];
static uint32_t g_rx_len = 0;
static void cb_tx_done(const dwt_cb_data_t *) { g_tx_done = true; }
static void cb_rx_ok(const dwt_cb_data_t *d) {
g_rx_len = d->datalength;
if (g_rx_len > sizeof(g_rx_buf)) g_rx_len = sizeof(g_rx_buf);
dwt_readrxdata(g_rx_buf, g_rx_len, 0);
g_rx_ok = true;
}
static void cb_rx_err(const dwt_cb_data_t *) { g_rx_err = true; }
static void cb_rx_to(const dwt_cb_data_t *) { g_rx_to = true; }
/* ── Timestamp helpers ──────────────────────────────────────────── */
static uint64_t ts_read(const uint8_t *p) {
uint64_t v = 0;
for (int i = 4; i >= 0; i--) v = (v << 8) | p[i];
return v;
}
static void ts_write(uint8_t *p, uint64_t v) {
for (int i = 0; i < 5; i++, v >>= 8) p[i] = (uint8_t)(v & 0xFF);
}
static inline uint64_t ts_diff(uint64_t later, uint64_t earlier) {
return (later - earlier) & DWT_TS_MASK;
}
static inline double ticks_to_s(uint64_t t) {
return (double)t / (128.0 * 499200000.0);
}
static float rx_power_dbm(void) {
dwt_rxdiag_t d;
dwt_readdiagnostics(&d);
if (d.maxGrowthCIR == 0 || d.rxPreamCount == 0) return 0.0f;
float f = (float)d.maxGrowthCIR;
float n = (float)d.rxPreamCount;
return 10.0f * log10f((f * f) / (n * n)) - 121.74f;
}
/* ── Shared state for display ───────────────────────────────────── */
static int32_t g_anchor_range_mm[NUM_ANCHORS]; /* last range per anchor */
static float g_anchor_rssi[NUM_ANCHORS]; /* last RSSI per anchor */
static uint32_t g_anchor_last_ok[NUM_ANCHORS]; /* millis() of last good range */
static bool g_estop_active = false;
/* ── ESP-NOW send helper ────────────────────────────────────────── */
static void espnow_send(uint8_t msg_type, uint8_t anchor_id,
int32_t range_mm, float rssi) {
EspNowPacket pkt = {};
pkt.magic[0] = ESPNOW_MAGIC_0;
pkt.magic[1] = ESPNOW_MAGIC_1;
pkt.tag_id = TAG_ID;
pkt.msg_type = msg_type;
pkt.anchor_id = anchor_id;
pkt.range_mm = range_mm;
pkt.rssi_dbm = rssi;
pkt.timestamp_ms = millis();
pkt.battery_pct = 0xFF; /* TODO: read ADC battery voltage */
pkt.flags = g_estop_active ? 0x01 : 0x00;
pkt.seq_num = g_seq++;
esp_now_send(broadcast_mac, (uint8_t *)&pkt, sizeof(pkt));
}
/* ── E-Stop handling ────────────────────────────────────────────── */
static uint32_t g_estop_last_tx = 0;
static void estop_check(void) {
bool pressed = (digitalRead(PIN_ESTOP) == LOW);
if (pressed && !g_estop_active) {
/* Just pressed — enter e-stop */
g_estop_active = true;
Serial.println("+ESTOP:ACTIVE");
}
if (g_estop_active && pressed) {
/* While held: send e-stop at 10 Hz */
if (millis() - g_estop_last_tx >= 100) {
espnow_send(MSG_ESTOP, 0xFF, 0, 0.0f);
g_estop_last_tx = millis();
}
}
if (!pressed && g_estop_active) {
/* Released: send 3x clear packets, resume */
for (int i = 0; i < 3; i++) {
g_estop_active = false; /* clear flag before sending so flags=0 */
espnow_send(MSG_ESTOP, 0xFF, 0, 0.0f);
delay(10);
}
g_estop_active = false;
Serial.println("+ESTOP:CLEAR");
}
}
/* ── OLED display update (5 Hz) ─────────────────────────────────── */
static uint32_t g_display_last = 0;
static void display_update(void) {
if (millis() - g_display_last < 200) return;
g_display_last = millis();
display.clearDisplay();
if (g_estop_active) {
/* Big E-STOP warning */
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
display.setCursor(10, 4);
display.println(F("E-STOP"));
display.setTextSize(1);
display.setCursor(20, 48);
display.println(F("RELEASE TO CLEAR"));
display.display();
return;
}
uint32_t now = millis();
/* Find closest anchor */
int32_t min_range = INT32_MAX;
for (int i = 0; i < NUM_ANCHORS; i++) {
if (g_anchor_range_mm[i] > 0 && g_anchor_range_mm[i] < min_range)
min_range = g_anchor_range_mm[i];
}
/* Line 1: Big distance to nearest anchor */
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
if (min_range < INT32_MAX && min_range > 0) {
float m = min_range / 1000.0f;
if (m < 10.0f)
display.printf("%.1fm", m);
else
display.printf("%.0fm", m);
} else {
display.println(F("---"));
}
/* Line 2: Both anchor ranges */
display.setTextSize(1);
display.setCursor(0, 30);
for (int i = 0; i < NUM_ANCHORS && i < 2; i++) {
if (g_anchor_range_mm[i] > 0) {
float m = g_anchor_range_mm[i] / 1000.0f;
display.printf("A%d:%.1fm ", i, m);
} else {
display.printf("A%d:--- ", i);
}
}
/* Line 3: Connection status */
display.setCursor(0, 42);
bool any_linked = false;
for (int i = 0; i < NUM_ANCHORS; i++) {
if (g_anchor_last_ok[i] > 0 && (now - g_anchor_last_ok[i]) < 2000) {
any_linked = true;
break;
}
}
if (any_linked) {
/* RSSI bar: map -90..-30 dBm to 0-5 bars */
float best_rssi = -100.0f;
for (int i = 0; i < NUM_ANCHORS; i++) {
if (g_anchor_rssi[i] > best_rssi) best_rssi = g_anchor_rssi[i];
}
int bars = constrain((int)((best_rssi + 90.0f) / 12.0f), 0, 5);
display.print(F("LINKED "));
/* Draw signal bars */
for (int b = 0; b < 5; b++) {
int x = 50 + b * 6;
int h = 2 + b * 2;
int y = 50 - h;
if (b < bars)
display.fillRect(x, y, 4, h, SSD1306_WHITE);
else
display.drawRect(x, y, 4, h, SSD1306_WHITE);
}
display.printf(" %.0fdB", best_rssi);
} else {
display.println(F("LOST -- searching --"));
}
/* Line 4: Uptime */
display.setCursor(0, 54);
uint32_t secs = now / 1000;
display.printf("UP %02d:%02d seq:%d", secs / 60, secs % 60, g_seq);
display.display();
}
/* ── DS-TWR initiator (one anchor, one cycle) ───────────────────── */
static int32_t twr_range_once(uint8_t anchor_id) {
/* --- 1. TX POLL --- */
uint8_t poll[POLL_FRAME_LEN];
poll[0] = FTYPE_POLL;
poll[1] = TAG_ID;
poll[2] = anchor_id;
dwt_writetxdata(POLL_FRAME_LEN - FCS_LEN, poll, 0);
dwt_writetxfctrl(POLL_FRAME_LEN, 0, 1);
dwt_setrxaftertxdelay(300);
dwt_setrxtimeout(RESP_RX_TIMEOUT_US);
g_tx_done = g_rx_ok = g_rx_err = g_rx_to = false;
if (dwt_starttx(DWT_START_TX_IMMEDIATE | DWT_RESPONSE_EXPECTED) != DWT_SUCCESS)
return -1;
uint32_t t0 = millis();
while (!g_tx_done && millis() - t0 < 15) yield();
uint8_t poll_tx_raw[5];
dwt_readtxtimestamp(poll_tx_raw);
uint64_t T_poll_tx = ts_read(poll_tx_raw);
/* --- 2. Wait for RESP --- */
t0 = millis();
while (!g_rx_ok && !g_rx_err && !g_rx_to && millis() - t0 < 60) yield();
if (!g_rx_ok || g_rx_len < FRAME_HDR + RESP_PAYLOAD) return -1;
if (g_rx_buf[0] != FTYPE_RESP) return -1;
if (g_rx_buf[2] != TAG_ID) return -1;
if (g_rx_buf[1] != anchor_id) return -1;
uint8_t resp_rx_raw[5];
dwt_readrxtimestamp(resp_rx_raw);
uint64_t T_resp_rx = ts_read(resp_rx_raw);
uint64_t T_poll_rx_a = ts_read(&g_rx_buf[3]);
uint64_t T_resp_tx_a = ts_read(&g_rx_buf[8]);
/* --- 3. Compute DS-TWR values for FINAL --- */
uint64_t Ra = ts_diff(T_resp_rx, T_poll_tx);
uint64_t Db = ts_diff(T_resp_tx_a, T_poll_rx_a);
uint64_t final_tx_sched = (T_resp_rx + FINAL_TX_DLY_TICKS) & ~0x1FFULL;
uint64_t Da = ts_diff(final_tx_sched, T_resp_rx);
/* --- 4. TX FINAL --- */
uint8_t final_buf[FINAL_FRAME_LEN];
final_buf[0] = FTYPE_FINAL;
final_buf[1] = TAG_ID;
final_buf[2] = anchor_id;
ts_write(&final_buf[3], Ra);
ts_write(&final_buf[8], Da);
ts_write(&final_buf[13], Db);
dwt_writetxdata(FINAL_FRAME_LEN - FCS_LEN, final_buf, 0);
dwt_writetxfctrl(FINAL_FRAME_LEN, 0, 1);
dwt_setdelayedtrxtime((uint32_t)(final_tx_sched >> 8));
g_tx_done = false;
if (dwt_starttx(DWT_START_TX_DELAYED) != DWT_SUCCESS) {
dwt_forcetrxoff();
return -1;
}
t0 = millis();
while (!g_tx_done && millis() - t0 < 15) yield();
/* --- 5. Local range estimate (debug) --- */
uint8_t final_tx_raw[5];
dwt_readtxtimestamp(final_tx_raw);
/* uint64_t T_final_tx = ts_read(final_tx_raw); -- unused, tag uses SS estimate */
double ra = ticks_to_s(Ra);
double db = ticks_to_s(Db);
double tof = (ra - db) / 2.0;
double range_m = tof * SPEED_OF_LIGHT;
if (range_m < 0.1 || range_m > 130.0) return -1;
return (int32_t)(range_m * 1000.0 + 0.5);
}
/* ── Setup ──────────────────────────────────────────────────────── */
void setup(void) {
Serial.begin(SERIAL_BAUD);
delay(300);
/* E-Stop button */
pinMode(PIN_ESTOP, INPUT_PULLUP);
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LOW);
Serial.printf("\r\n[uwb_tag] tag_id=0x%02X num_anchors=%d starting\r\n",
TAG_ID, NUM_ANCHORS);
/* --- OLED init --- */
Wire.begin(PIN_SDA, PIN_SCL);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("[uwb_tag] WARN: SSD1306 not found — running headless");
} else {
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("SaltyBot"));
display.setTextSize(1);
display.setCursor(0, 20);
display.printf("Tag 0x%02X v2.0", TAG_ID);
display.setCursor(0, 35);
display.println(F("DW3000 DS-TWR + ESP-NOW"));
display.setCursor(0, 50);
display.println(F("Initializing..."));
display.display();
Serial.println("[uwb_tag] OLED ok");
}
/* --- ESP-NOW init --- */
WiFi.mode(WIFI_STA);
WiFi.disconnect();
/* Set WiFi channel to match anchors (default ch 1) */
esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() != ESP_OK) {
Serial.println("[uwb_tag] FATAL: esp_now_init failed");
for (;;) delay(1000);
}
/* Add broadcast peer */
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, broadcast_mac, 6);
peer.channel = 0; /* use current channel */
peer.encrypt = false;
esp_now_add_peer(&peer);
Serial.println("[uwb_tag] ESP-NOW ok");
/* --- DW3000 init --- */
SPI.begin(PIN_SCK, PIN_MISO, PIN_MOSI, PIN_CS);
pinMode(PIN_RST, OUTPUT);
digitalWrite(PIN_RST, LOW);
delay(2);
pinMode(PIN_RST, INPUT_PULLUP);
delay(5);
if (dwt_probe((struct dwt_probe_s *)&dw3000_probe_interf)) {
Serial.println("[uwb_tag] FATAL: DW3000 probe failed");
for (;;) delay(1000);
}
if (dwt_initialise(DWT_DW_INIT) != DWT_SUCCESS) {
Serial.println("[uwb_tag] FATAL: dwt_initialise failed");
for (;;) delay(1000);
}
if (dwt_configure(&dw_cfg) != DWT_SUCCESS) {
Serial.println("[uwb_tag] FATAL: dwt_configure failed");
for (;;) delay(1000);
}
dwt_setrxantennadelay(ANT_DELAY);
dwt_settxantennadelay(ANT_DELAY);
dwt_settxpower(0x0E080222UL);
dwt_setcallbacks(cb_tx_done, cb_rx_ok, cb_rx_to, cb_rx_err,
nullptr, nullptr, nullptr);
dwt_setinterrupt(
DWT_INT_TXFRS | DWT_INT_RFCG | DWT_INT_RFTO |
DWT_INT_RFSL | DWT_INT_SFDT | DWT_INT_ARFE | DWT_INT_CPERR,
0, DWT_ENABLE_INT_ONLY);
attachInterrupt(digitalPinToInterrupt(PIN_IRQ),
[]() { dwt_isr(); }, RISING);
/* Init range state */
for (int i = 0; i < NUM_ANCHORS; i++) {
g_anchor_range_mm[i] = -1;
g_anchor_rssi[i] = -100.0f;
g_anchor_last_ok[i] = 0;
}
Serial.printf("[uwb_tag] DW3000 ready ch=%d 6.8Mbps tag=0x%02X\r\n",
dw_cfg.chan, TAG_ID);
Serial.println("[uwb_tag] Ranging + ESP-NOW + display active");
}
/* ── Main loop ──────────────────────────────────────────────────── */
void loop(void) {
static uint8_t anchor_idx = 0;
static uint32_t last_range_ms = 0;
static uint32_t last_hb_ms = 0;
/* E-Stop always has priority */
estop_check();
if (g_estop_active) {
display_update();
return; /* skip ranging while e-stop active */
}
/* Heartbeat every 1 second */
uint32_t now = millis();
if (now - last_hb_ms >= 1000) {
espnow_send(MSG_HEARTBEAT, 0xFF, 0, 0.0f);
last_hb_ms = now;
}
/* Ranging at configured interval */
if (now - last_range_ms >= RANGE_INTERVAL_MS) {
last_range_ms = now;
uint8_t anchor_id = anchor_idx % NUM_ANCHORS;
int32_t range_mm = twr_range_once(anchor_id);
if (range_mm > 0) {
float rssi = rx_power_dbm();
/* Update shared state for display */
g_anchor_range_mm[anchor_id] = range_mm;
g_anchor_rssi[anchor_id] = rssi;
g_anchor_last_ok[anchor_id] = now;
/* Serial debug */
Serial.printf("+RANGE:%d,%ld,%.1f\r\n",
anchor_id, (long)range_mm, rssi);
/* ESP-NOW broadcast */
espnow_send(MSG_RANGE, anchor_id, range_mm, rssi);
/* LED blink */
digitalWrite(PIN_LED, HIGH);
delay(2);
digitalWrite(PIN_LED, LOW);
}
anchor_idx++;
if (anchor_idx >= NUM_ANCHORS) anchor_idx = 0;
}
/* Display at 5 Hz (non-blocking) */
display_update();
}

View File

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

View File

@ -0,0 +1,22 @@
idf_component_register(
SRCS
"main.c"
"orin_serial.c"
"vesc_can.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
cJSON
driver
freertos
esp_timer
)

View File

@ -0,0 +1,42 @@
#pragma once
/* ── ESP32-S3 BALANCE board — bd-66hx pin/config definitions ───────────────
*
* Hardware change from pre-bd-66hx design:
* Previously: IO43/IO44 = CAN SN65HVD230 (shared Orin+VESC bus via CANable2)
* After bd-66hx: IO43/IO44 = CH343 UART0 (Orin serial comms)
* IO2/IO1 = CAN SN65HVD230 rewired (VESC-only bus)
*
* The SN65HVD230 transceiver physical wiring must be updated from IO43/44
* to IO2/IO1 when deploying this firmware. See docs/SAUL-TEE-SYSTEM-REFERENCE.md.
*/
/* ── 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→CH343 RXD */
#define ORIN_UART_RX_GPIO 44 /* CH343 TXD→ESP32 */
#define ORIN_UART_RX_BUF 1024
#define ORIN_TX_QUEUE_DEPTH 16
/* ── VESC CAN TWAI (SN65HVD230 transceiver, rewired for bd-66hx) ── */
#define VESC_CAN_TX_GPIO 2 /* ESP32 TWAI TX → SN65HVD230 TXD */
#define VESC_CAN_RX_GPIO 1 /* SN65HVD230 RXD → ESP32 TWAI RX */
#define VESC_CAN_RX_QUEUE 32
/* VESC node IDs — matched to bd-wim1 TELEM_VESC_LEFT/RIGHT mapping */
#define VESC_ID_A 56u /* TELEM_VESC_LEFT (0x81) */
#define VESC_ID_B 68u /* TELEM_VESC_RIGHT (0x82) */
/* ── 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 */
#define RPM_PER_STEER_UNIT 3 /* steer differential scale */
/* ── Tilt cutoff ── */
#define TILT_CUTOFF_DEG 25.0f

View File

@ -0,0 +1,285 @@
/* 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

@ -0,0 +1,42 @@
#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);

114
esp32s3/balance/main/main.c Normal file
View File

@ -0,0 +1,114 @@
/* main.c — ESP32-S3 BALANCE app_main (bd-66hx + OTA beads) */
#include "orin_serial.h"
#include "vesc_can.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 left_erpm = 0;
int32_t right_erpm = 0;
if (g_orin_ctrl.armed && !g_orin_ctrl.estop &&
!hb_timeout && !drive_stale) {
int32_t spd = (int32_t)g_orin_drive.speed * RPM_PER_SPEED_UNIT;
int32_t str = (int32_t)g_orin_drive.steer * RPM_PER_STEER_UNIT;
left_erpm = spd + str;
right_erpm = spd - str;
}
/* VESC_ID_A (56) = LEFT, VESC_ID_B (68) = RIGHT per bd-wim1 protocol */
vesc_can_send_rpm(VESC_ID_A, left_erpm);
vesc_can_send_rpm(VESC_ID_B, right_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 */
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

@ -0,0 +1,354 @@
/* 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_pid_t g_orin_pid = {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_PID:
if (len < 12u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; }
/* float32 big-endian: copy and swap bytes */
{
uint32_t raw;
raw = ((uint32_t)payload[0] << 24u) | ((uint32_t)payload[1] << 16u) |
((uint32_t)payload[2] << 8u) | (uint32_t)payload[3];
memcpy((void*)&g_orin_pid.kp, &raw, 4u);
raw = ((uint32_t)payload[4] << 24u) | ((uint32_t)payload[5] << 16u) |
((uint32_t)payload[6] << 8u) | (uint32_t)payload[7];
memcpy((void*)&g_orin_pid.ki, &raw, 4u);
raw = ((uint32_t)payload[8] << 24u) | ((uint32_t)payload[9] << 16u) |
((uint32_t)payload[10] << 8u) | (uint32_t)payload[11];
memcpy((void*)&g_orin_pid.kd, &raw, 4u);
g_orin_pid.updated = true;
}
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

@ -0,0 +1,113 @@
#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 */
#define CMD_PID 0x05u /* float32 kp, ki, kd, BE */
/* ── Telemetry types: ESP32 → Orin ── */
#define TELEM_STATUS 0x80u /* status @ 10 Hz */
#define TELEM_VESC_LEFT 0x81u /* VESC ID 56 telemetry @ 10 Hz */
#define TELEM_VESC_RIGHT 0x82u /* VESC ID 68 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
/* ── Balance state (mirrored from TELEM_STATUS.balance_state) ── */
typedef enum {
BAL_DISARMED = 0,
BAL_ARMED = 1,
BAL_TILT_FAULT = 2,
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 float kp, ki, kd;
volatile bool updated;
} orin_pid_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_pid_t g_orin_pid;
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

@ -0,0 +1,150 @@
/* 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[32];
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

@ -0,0 +1,33 @@
#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

@ -0,0 +1,183 @@
/* 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

@ -0,0 +1,34 @@
#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

@ -0,0 +1,241 @@
/* 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

@ -0,0 +1,64 @@
#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

@ -0,0 +1,14 @@
#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

@ -0,0 +1,119 @@
/* 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

@ -0,0 +1,36 @@
#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

@ -0,0 +1,7 @@
# 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

@ -0,0 +1,19 @@
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

View File

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

View File

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

35
esp32s3/io/main/config.h Normal file
View File

@ -0,0 +1,35 @@
#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"

42
esp32s3/io/main/main.c Normal file
View File

@ -0,0 +1,42 @@
/* 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

@ -0,0 +1,210 @@
/* 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

@ -0,0 +1,20 @@
#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

@ -0,0 +1,7 @@
# 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

@ -0,0 +1,13 @@
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

@ -1,152 +0,0 @@
#ifndef JLINK_H
#define JLINK_H
#include <stdint.h>
#include <stdbool.h>
/*
* JLink Jetson serial binary protocol over USART1 (PB6=TX, PB7=RX).
*
* Issue #120: replaces jetson_cmd ASCII-over-USB-CDC with a dedicated
* hardware UART at 921600 baud using DMA circular RX and IDLE interrupt.
*
* Frame format (both directions):
* [STX=0x02][LEN][CMD][PAYLOAD...][CRC16_hi][CRC16_lo][ETX=0x03]
*
* STX : frame start sentinel (0x02)
* LEN : count of CMD + PAYLOAD bytes (1 + payload_len)
* CMD : command/telemetry type byte
* PAYLOAD: 0..N bytes depending on CMD
* CRC16 : CRC16-XModem over CMD+PAYLOAD (poly 0x1021, init 0), big-endian
* ETX : frame end sentinel (0x03)
*
* Jetson STM32 commands:
* 0x01 HEARTBEAT no payload; refreshes heartbeat timer
* 0x02 DRIVE int16 speed (-1000..+1000), int16 steer (-1000..+1000)
* 0x03 ARM no payload; request arm (same interlock as CDC 'A')
* 0x04 DISARM no payload; disarm immediately
* 0x05 PID_SET float kp, float ki, float kd (12 bytes, IEEE-754 LE)
* 0x06 DFU_ENTER no payload; request OTA DFU reboot (denied while armed)
* 0x07 ESTOP no payload; engage emergency stop
*
* STM32 Jetson telemetry:
* 0x80 STATUS jlink_tlm_status_t (20 bytes), sent at JLINK_TLM_HZ
*
* Priority: CRSF RC always takes precedence. Jetson steer/speed only applied
* when mode_manager_active() == MODE_AUTONOMOUS (CH6 high). In RC_MANUAL and
* RC_ASSISTED modes the Jetson speed offset and steer are injected via
* mode_manager_set_auto_cmd() and blended per the existing blend ramp.
*
* Heartbeat: if no valid frame arrives within JLINK_HB_TIMEOUT_MS (1000ms),
* jlink_is_active() returns false and the main loop clears the auto command.
*/
/* ---- Frame constants ---- */
#define JLINK_STX 0x02u
#define JLINK_ETX 0x03u
/* ---- Command IDs (Jetson → STM32) ---- */
#define JLINK_CMD_HEARTBEAT 0x01u
#define JLINK_CMD_DRIVE 0x02u
#define JLINK_CMD_ARM 0x03u
#define JLINK_CMD_DISARM 0x04u
#define JLINK_CMD_PID_SET 0x05u
#define JLINK_CMD_DFU_ENTER 0x06u
#define JLINK_CMD_ESTOP 0x07u
#define JLINK_CMD_AUDIO 0x08u /* PCM audio chunk: int16 samples, up to 126 */
#define JLINK_CMD_SLEEP 0x09u /* no payload; request STOP-mode sleep */
/* ---- Telemetry IDs (STM32 → Jetson) ---- */
#define JLINK_TLM_STATUS 0x80u
#define JLINK_TLM_POWER 0x81u /* jlink_tlm_power_t (11 bytes) */
/* ---- Telemetry STATUS payload (20 bytes, packed) ---- */
typedef struct __attribute__((packed)) {
int16_t pitch_x10; /* pitch degrees ×10 */
int16_t roll_x10; /* roll degrees ×10 */
int16_t yaw_x10; /* yaw degrees ×10 (gyro-integrated) */
int16_t motor_cmd; /* ESC output -1000..+1000 */
uint16_t vbat_mv; /* battery millivolts */
int8_t rssi_dbm; /* CRSF RSSI (dBm, negative) */
uint8_t link_quality; /* CRSF LQ 0-100 */
uint8_t balance_state; /* 0=DISARMED, 1=ARMED, 2=TILT_FAULT */
uint8_t rc_armed; /* crsf_state.armed (1=armed) */
uint8_t mode; /* robot_mode_t: 0=RC_MANUAL,1=ASSISTED,2=AUTONOMOUS */
uint8_t estop; /* EstopSource value */
uint8_t soc_pct; /* state-of-charge 0-100, 255=unknown */
uint8_t fw_major;
uint8_t fw_minor;
uint8_t fw_patch;
} jlink_tlm_status_t; /* 20 bytes */
/* ---- Telemetry POWER payload (11 bytes, packed) ---- */
typedef struct __attribute__((packed)) {
uint8_t power_state; /* PowerState: 0=ACTIVE,1=SLEEP_PENDING,2=SLEEPING,3=WAKING */
uint16_t est_total_ma; /* estimated total current draw (mA) */
uint16_t est_audio_ma; /* estimated I2S3+amp current (mA); 0 if gated */
uint16_t est_osd_ma; /* estimated OSD SPI2 current (mA); 0 if gated */
uint32_t idle_ms; /* ms since last cmd_vel activity */
} jlink_tlm_power_t; /* 11 bytes */
/* ---- Volatile state (read from main loop) ---- */
typedef struct {
/* Drive command — updated on JLINK_CMD_DRIVE */
volatile int16_t speed; /* -1000..+1000 */
volatile int16_t steer; /* -1000..+1000 */
/* Heartbeat timer — updated on any valid frame */
volatile uint32_t last_rx_ms; /* HAL_GetTick() of last valid frame; 0=none */
/* One-shot request flags — set by parser, cleared by main loop */
volatile uint8_t arm_req;
volatile uint8_t disarm_req;
volatile uint8_t estop_req;
/* PID update — set by parser, cleared by main loop */
volatile uint8_t pid_updated;
volatile float pid_kp;
volatile float pid_ki;
volatile float pid_kd;
/* DFU reboot request — set by parser, cleared by main loop */
volatile uint8_t dfu_req;
/* Sleep request — set by JLINK_CMD_SLEEP, cleared by main loop */
volatile uint8_t sleep_req;
} JLinkState;
extern volatile JLinkState jlink_state;
/* ---- API ---- */
/*
* jlink_init() configure USART1 (PB6=TX, PB7=RX) at 921600 baud with
* DMA2_Stream2_Channel4 circular RX (128-byte buffer) and IDLE interrupt.
* Call once before safety_init().
*/
void jlink_init(void);
/*
* jlink_is_active(now_ms) returns true if a valid frame arrived within
* JLINK_HB_TIMEOUT_MS. Returns false if no frame ever received.
*/
bool jlink_is_active(uint32_t now_ms);
/*
* jlink_send_telemetry(status) build and transmit a JLINK_TLM_STATUS frame
* over USART1 TX (blocking, ~0.2ms at 921600). Call at JLINK_TLM_HZ.
*/
void jlink_send_telemetry(const jlink_tlm_status_t *status);
/*
* jlink_process() drain DMA circular buffer and parse frames.
* Call from main loop every iteration (not ISR). Lightweight: O(bytes_pending).
*/
void jlink_process(void);
/*
* jlink_send_power_telemetry(power) build and transmit a JLINK_TLM_POWER
* frame (17 bytes) at PM_TLM_HZ. Call from main loop when not in STOP mode.
*/
void jlink_send_power_telemetry(const jlink_tlm_power_t *power);
#endif /* JLINK_H */

View File

@ -2,7 +2,7 @@
# Base: JetPack 6 (L4T R36.2.0) + CUDA 12.x / Ubuntu 22.04 # Base: JetPack 6 (L4T R36.2.0) + CUDA 12.x / Ubuntu 22.04
# #
# Hardware: Jetson Orin Nano Super 8GB (67 TOPS, 1024-core Ampere) # Hardware: Jetson Orin Nano Super 8GB (67 TOPS, 1024-core Ampere)
# Previous: Jetson Nano 4GB (JetPack 4.6 / L4T R32.6.1) — see git history # Previous: Jetson Orin Nano Super 4GB (JetPack 4.6 / L4T R32.6.1) — see git history
FROM nvcr.io/nvidia/l4t-jetpack:r36.2.0 FROM nvcr.io/nvidia/l4t-jetpack:r36.2.0

View File

@ -1,12 +1,12 @@
# Jetson Nano — AI/SLAM Platform Setup # Jetson Orin Nano Super — AI/SLAM Platform Setup
Self-balancing robot: Jetson Nano dev environment for ROS2 Humble + SLAM stack. Self-balancing robot: Jetson Orin Nano Super dev environment for ROS2 Humble + SLAM stack.
## Stack ## Stack
| Component | Version / Part | | Component | Version / Part |
|-----------|---------------| |-----------|---------------|
| Platform | Jetson Nano 4GB | | Platform | Jetson Orin Nano Super 4GB |
| JetPack | 4.6 (L4T R32.6.1, CUDA 10.2) | | JetPack | 4.6 (L4T R32.6.1, CUDA 10.2) |
| ROS2 | Humble Hawksbill | | ROS2 | Humble Hawksbill |
| DDS | CycloneDDS | | DDS | CycloneDDS |
@ -14,7 +14,11 @@ Self-balancing robot: Jetson Nano dev environment for ROS2 Humble + SLAM stack.
| Nav | Nav2 | | Nav | Nav2 |
| Depth camera | Intel RealSense D435i | | Depth camera | Intel RealSense D435i |
| LiDAR | RPLIDAR A1M8 | | LiDAR | RPLIDAR A1M8 |
| MCU bridge | STM32F722 (USB CDC @ 921600) | <<<<<<< HEAD
| MCU bridge | ESP32 (USB CDC @ 921600) |
=======
| MCU bridge | ESP32-S3 (USB Serial (CH343) @ 921600) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Quick Start ## Quick Start
@ -42,7 +46,11 @@ bash scripts/build-and-run.sh shell
``` ```
jetson/ jetson/
├── Dockerfile # L4T base + ROS2 Humble + SLAM packages ├── Dockerfile # L4T base + ROS2 Humble + SLAM packages
├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, STM32) <<<<<<< HEAD
├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, ESP32 BALANCE)
=======
├── docker-compose.yml # Multi-service stack (ROS2, RPLIDAR, D435i, ESP32-S3)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
├── README.md # This file ├── README.md # This file
├── docs/ ├── docs/
│ ├── pinout.md # GPIO/I2C/UART pinout reference │ ├── pinout.md # GPIO/I2C/UART pinout reference

View File

@ -34,7 +34,11 @@ 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.
Recovery behaviors cannot interfere with E-stop because the emergency system operates at the motor driver level on the STM32 firmware. <<<<<<< 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.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Behavior Tree Sequence ## Behavior Tree Sequence

View File

@ -12,7 +12,11 @@
# /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)
# #
# Output: /cmd_vel (Twist) — STM32 bridge consumes this topic. <<<<<<< HEAD
# Output: /cmd_vel (Twist) — ESP32 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:

View File

@ -31,7 +31,7 @@ services:
- ./config:/config:ro - ./config:/config:ro
devices: devices:
- /dev/rplidar:/dev/rplidar - /dev/rplidar:/dev/rplidar
- /dev/stm32-bridge:/dev/stm32-bridge - /dev/esp32-bridge:/dev/esp32-bridge
- /dev/bus/usb:/dev/bus/usb - /dev/bus/usb:/dev/bus/usb
- /dev/i2c-7:/dev/i2c-7 - /dev/i2c-7:/dev/i2c-7
- /dev/video0:/dev/video0 - /dev/video0:/dev/video0
@ -97,13 +97,17 @@ services:
rgb_camera.profile:=640x480x30 rgb_camera.profile:=640x480x30
" "
# ── STM32 bridge node (bidirectional serial<->ROS2) ──────────────────────── <<<<<<< HEAD
stm32-bridge: # ── ESP32 bridge node (bidirectional serial<->ROS2) ────────────────────────
=======
# ── ESP32-S3 bridge node (bidirectional serial<->ROS2) ────────────────────────
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
esp32-bridge:
image: saltybot/ros2-humble:jetson-orin image: saltybot/ros2-humble:jetson-orin
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: saltybot-stm32-bridge container_name: saltybot-esp32-bridge
restart: unless-stopped restart: unless-stopped
runtime: nvidia runtime: nvidia
network_mode: host network_mode: host
@ -111,13 +115,13 @@ services:
- ROS_DOMAIN_ID=42 - ROS_DOMAIN_ID=42
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp - RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
devices: devices:
- /dev/stm32-bridge:/dev/stm32-bridge - /dev/esp32-bridge:/dev/esp32-bridge
command: > command: >
bash -c " bash -c "
source /opt/ros/humble/setup.bash && source /opt/ros/humble/setup.bash &&
ros2 launch saltybot_bridge bridge.launch.py ros2 launch saltybot_bridge bridge.launch.py
mode:=bidirectional mode:=bidirectional
serial_port:=/dev/stm32-bridge serial_port:=/dev/esp32-bridge
" "
# ── 4x IMX219 CSI cameras ────────────────────────────────────────────────── # ── 4x IMX219 CSI cameras ──────────────────────────────────────────────────
@ -192,7 +196,7 @@ services:
network_mode: host network_mode: host
depends_on: depends_on:
- saltybot-ros2 - saltybot-ros2
- stm32-bridge - esp32-bridge
- csi-cameras - csi-cameras
environment: environment:
- ROS_DOMAIN_ID=42 - ROS_DOMAIN_ID=42
@ -208,8 +212,13 @@ services:
" "
# -- Remote e-stop bridge (MQTT over 4G -> STM32 CDC) ---------------------- <<<<<<< HEAD
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to STM32. # -- Remote e-stop bridge (MQTT over 4G -> ESP32 CDC) ----------------------
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to ESP32 BALANCE.
=======
# -- Remote e-stop bridge (MQTT over 4G -> ESP32-S3 CDC) ----------------------
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to ESP32-S3.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
# 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
@ -221,12 +230,12 @@ services:
runtime: nvidia runtime: nvidia
network_mode: host network_mode: host
depends_on: depends_on:
- stm32-bridge - esp32-bridge
environment: environment:
- ROS_DOMAIN_ID=42 - ROS_DOMAIN_ID=42
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp - RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
devices: devices:
- /dev/stm32-bridge:/dev/stm32-bridge - /dev/esp32-bridge:/dev/esp32-bridge
volumes: volumes:
- ./ros2_ws/src:/ros2_ws/src:rw - ./ros2_ws/src:/ros2_ws/src:rw
- ./config:/config:ro - ./config:/config:ro
@ -316,7 +325,7 @@ services:
runtime: nvidia runtime: nvidia
network_mode: host network_mode: host
depends_on: depends_on:
- stm32-bridge - esp32-bridge
environment: environment:
- NVIDIA_VISIBLE_DEVICES=all - NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=all,audio - NVIDIA_DRIVER_CAPABILITIES=all,audio

View File

@ -1,5 +1,9 @@
# Jetson Orin Nano Super — GPIO / I2C / UART / CSI Pinout Reference # Jetson Orin Nano Super — GPIO / I2C / UART / CSI Pinout Reference
## Self-Balancing Robot: STM32F722 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219 <<<<<<< HEAD
## Self-Balancing Robot: ESP32 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
=======
## Self-Balancing Robot: ESP32-S3 Bridge + RealSense D435i + RPLIDAR A1M8 + 4× IMX219
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
Last updated: 2026-02-28 Last updated: 2026-02-28
JetPack version: 6.x (L4T R36.x / Ubuntu 22.04) JetPack version: 6.x (L4T R36.x / Ubuntu 22.04)
@ -43,21 +47,37 @@ i2cdetect -l
--- ---
## 1. STM32F722 Bridge (USB CDC — Primary) <<<<<<< HEAD
## 1. ESP32 Bridge (USB CDC — Primary)
The STM32 acts as a real-time motor + IMU controller. Communication is via **USB CDC serial**. 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)
### USB CDC Connection 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
| Connection | Detail | | Connection | Detail |
|-----------|--------| |-----------|--------|
| Interface | USB Micro-B on STM32 dev board → USB-A on Jetson | <<<<<<< HEAD
| Device node | `/dev/ttyACM0` → symlink `/dev/stm32-bridge` (via udev) | | Interface | USB on ESP32 BALANCE board → USB-A on Jetson |
| Baud rate | 921600 (configured in STM32 firmware) | | 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 |
| Device node | `/dev/ttyACM0` → symlink `/dev/esp32-bridge` (via udev) |
| Baud rate | 921600 (configured in ESP32-S3 firmware) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Protocol | JSON telemetry RX + ASCII command TX (see bridge docs) | | 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)
| Jetson Pin | Signal | STM32 Pin | Notes | <<<<<<< HEAD
| Jetson Pin | Signal | ESP32 Pin | Notes |
=======
| Jetson Pin | Signal | ESP32-S3 Pin | Notes |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|-----------|--------|-----------|-------| |-----------|--------|-----------|-------|
| Pin 8 (TXD0) | TX → | PA10 (UART1 RX) | Cross-connect TX→RX | | Pin 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 |
@ -65,7 +85,11 @@ The STM32 acts as a real-time motor + IMU controller. Communication is via **USB
**Jetson device node:** `/dev/ttyTHS0` **Jetson device node:** `/dev/ttyTHS0`
**Baud rate:** 921600, 8N1 **Baud rate:** 921600, 8N1
**Voltage level:** 3.3V — both Jetson Orin and STM32F722 are 3.3V GPIO <<<<<<< HEAD
**Voltage level:** 3.3V — both Jetson Orin and ESP32 are 3.3V GPIO
=======
**Voltage level:** 3.3V — both Jetson Orin and ESP32-S3 are 3.3V GPIO
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```bash ```bash
# Verify UART # Verify UART
@ -75,13 +99,23 @@ sudo usermod -aG dialout $USER
picocom -b 921600 /dev/ttyTHS0 picocom -b 921600 /dev/ttyTHS0
``` ```
**ROS2 topics (STM32 bridge node):** <<<<<<< HEAD
**ROS2 topics (ESP32 bridge node):**
| ROS2 Topic | Direction | Content | | ROS2 Topic | Direction | Content |
|-----------|-----------|--------- |-----------|-----------|---------
| `/saltybot/imu` | STM32→Jetson | IMU data (accel, gyro) at 50Hz | | `/saltybot/imu` | ESP32 BALANCE→Jetson | IMU data (accel, gyro) at 50Hz |
| `/saltybot/balance_state` | STM32→Jetson | Motor cmd, pitch, state | | `/saltybot/balance_state` | ESP32 BALANCE→Jetson | Motor cmd, pitch, state |
| `/cmd_vel` | Jetson→STM32 | Velocity commands → `C<spd>,<str>\n` | | `/cmd_vel` | Jetson→ESP32 BALANCE | Velocity commands → `C<spd>,<str>\n` |
| `/saltybot/estop` | Jetson→STM32 | Emergency stop | | `/saltybot/estop` | Jetson→ESP32 BALANCE | Emergency stop |
=======
**ROS2 topics (ESP32-S3 bridge node):**
| ROS2 Topic | Direction | Content |
|-----------|-----------|---------
| `/saltybot/imu` | ESP32-S3→Jetson | IMU data (accel, gyro) at 50Hz |
| `/saltybot/balance_state` | ESP32-S3→Jetson | Motor cmd, pitch, state |
| `/cmd_vel` | Jetson→ESP32-S3 | Velocity commands → `C<spd>,<str>\n` |
| `/saltybot/estop` | Jetson→ESP32-S3 | Emergency stop |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -266,7 +300,11 @@ 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) |
| USB-C | USB 3.1 Gen 1 (+ DP) | STM32 CDC or host flash | <<<<<<< HEAD
| USB-C | USB 3.1 Gen 1 (+ DP) | ESP32 CDC or host flash |
=======
| USB-C | USB 3.1 Gen 1 (+ DP) | ESP32-S3 CDC or host flash |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Micro-USB | Debug/flash | JetPack flash only | | Micro-USB | Debug/flash | JetPack flash only |
--- ---
@ -277,10 +315,17 @@ 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) |
| 8 | TXD0 | 3.3V | UART TX → STM32 (fallback) | <<<<<<< HEAD
| 10 | RXD0 | 3.3V | UART RX ← STM32 (fallback) | | 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-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | STM32 CDC | | USB-C | — | 5V | ESP32 CDC |
=======
| 8 | TXD0 | 3.3V | UART TX → ESP32-S3 (fallback) |
| 10 | RXD0 | 3.3V | UART RX ← ESP32-S3 (fallback) |
| USB-A ×2 | — | 5V | D435i, RPLIDAR |
| USB-C | — | 5V | ESP32-S3 CDC |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| CSI-A (J5) | MIPI CSI-2 | — | Cameras front + left | | CSI-A (J5) | MIPI CSI-2 | — | Cameras front + left |
| CSI-B (J8) | MIPI CSI-2 | — | Cameras rear + right | | CSI-B (J8) | MIPI CSI-2 | — | Cameras rear + right |
| M.2 Key M | PCIe Gen3 ×4 | — | NVMe SSD | | M.2 Key M | PCIe Gen3 ×4 | — | NVMe SSD |
@ -298,9 +343,13 @@ 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"
# STM32 USB CDC (STMicroelectronics) <<<<<<< HEAD
# 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", \ KERNEL=="ttyACM*", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", \
SYMLINK+="stm32-bridge", MODE="0666" SYMLINK+="esp32-bridge", MODE="0666"
# Intel RealSense D435i # Intel RealSense D435i
SUBSYSTEM=="usb", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b3a", \ SUBSYSTEM=="usb", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b3a", \

View File

@ -56,7 +56,11 @@ 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 |
| STM32F722 bridge | 0.0 | 0.0 | 0.0 | USB CDC | Self-powered from robot 5V | <<<<<<< HEAD
| ESP32 bridge | 0.0 | 0.0 | 0.0 | USB CDC | Self-powered from robot 5V |
=======
| ESP32-S3 bridge | 0.0 | 0.0 | 0.0 | USB Serial (CH343) | Self-powered from robot 5V |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 4× IMX219 cameras | 0.2 | 2.0 | 2.4 | MIPI CSI-2 | ~0.5W per camera active | | 4× IMX219 cameras | 0.2 | 2.0 | 2.4 | MIPI CSI-2 | ~0.5W per camera active |
| **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | | | **Peripheral Subtotal** | **0.9** | **6.1** | **8.9** | | |
@ -72,7 +76,7 @@ sudo jtop
## Budget Analysis vs Previous Platform ## Budget Analysis vs Previous Platform
| Metric | Jetson Nano | Jetson Orin Nano Super | | Metric | Jetson Orin Nano Super | Jetson Orin Nano Super |
|--------|------------|------------------------| |--------|------------|------------------------|
| TDP | 10W | 25W | | TDP | 10W | 25W |
| CPU | 4× Cortex-A57 @ 1.43GHz | 6× A78AE @ 1.5GHz | | CPU | 4× Cortex-A57 @ 1.43GHz | 6× A78AE @ 1.5GHz |
@ -151,7 +155,11 @@ 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)
├─► DC-DC Buck → 5V 3A ──► STM32 + logic 5V rail <<<<<<< HEAD
├─► 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

@ -0,0 +1,34 @@
aruco_detect:
ros__parameters:
# ── Marker specification ─────────────────────────────────────────────────
marker_size_m: 0.10 # physical side length of printed ArUco markers (m)
# measure the black border edge-to-edge
# ── Dock target filter ───────────────────────────────────────────────────
# dock_marker_ids: IDs that may serve as dock target.
# - Empty list [] → any detected marker is a dock candidate (closest wins)
# - Non-empty → only listed IDs are candidates; others still appear in
# /saltybot/aruco/markers but not in dock_target
# The saltybot dock uses marker ID 42 on the charging face by convention.
dock_marker_ids: [42]
# Maximum distance to consider a marker as dock target (m)
max_dock_range_m: 3.0
# ── ArUco dictionary ─────────────────────────────────────────────────────
aruco_dict: DICT_4X4_50 # cv2.aruco constant name
# Corner sub-pixel refinement (improves pose accuracy at close range)
# CORNER_REFINE_NONE — fastest, no refinement
# CORNER_REFINE_SUBPIX — best for high-contrast markers (recommended)
# CORNER_REFINE_CONTOUR — good for blurry or low-res images
corner_refinement: CORNER_REFINE_SUBPIX
# ── Frame / topic config ─────────────────────────────────────────────────
camera_frame: camera_color_optical_frame
# ── Debug image output (disabled by default) ─────────────────────────────
# When true, publishes annotated BGR image with markers + axes drawn.
# Useful for tuning but costs ~10 ms/frame encoding overhead.
draw_debug_image: false
debug_image_topic: /saltybot/aruco/debug_image

View File

@ -0,0 +1,29 @@
"""aruco_detect.launch.py — ArUco marker detection for docking (Issue #627)."""
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():
pkg_share = get_package_share_directory('saltybot_aruco_detect')
default_params = os.path.join(pkg_share, 'config', 'aruco_detect_params.yaml')
params_arg = DeclareLaunchArgument(
'params_file',
default_value=default_params,
description='Path to aruco_detect_params.yaml',
)
aruco_node = Node(
package='saltybot_aruco_detect',
executable='aruco_detect',
name='aruco_detect',
output='screen',
parameters=[LaunchConfiguration('params_file')],
)
return LaunchDescription([params_arg, aruco_node])

View File

@ -0,0 +1,37 @@
<?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_aruco_detect</name>
<version>0.1.0</version>
<description>
ArUco marker detection for docking alignment (Issue #627).
Detects all DICT_4X4_50 markers from RealSense D435i RGB, estimates 6-DOF
pose with estimatePoseSingleMarkers, publishes PoseArray on
/saltybot/aruco/markers and closest dock-candidate marker on
/saltybot/aruco/dock_target (PoseStamped) with RViz MarkerArray and
JSON status.
</description>
<maintainer email="sl-perception@saltylab.local">sl-perception</maintainer>
<license>MIT</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>std_msgs</depend>
<depend>sensor_msgs</depend>
<depend>geometry_msgs</depend>
<depend>visualization_msgs</depend>
<depend>cv_bridge</depend>
<exec_depend>python3-numpy</exec_depend>
<exec_depend>python3-opencv</exec_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

@ -0,0 +1,518 @@
"""
aruco_detect_node.py ArUco marker detection for docking alignment (Issue #627).
Detects all DICT_4X4_50 ArUco markers visible in the RealSense D435i RGB stream,
estimates their 6-DOF poses relative to the camera using estimatePoseSingleMarkers,
and publishes:
/saltybot/aruco/markers geometry_msgs/PoseArray all detected markers
/saltybot/aruco/dock_target geometry_msgs/PoseStamped closest dock marker
/saltybot/aruco/viz visualization_msgs/MarkerArray RViz axes overlays
/saltybot/aruco/status std_msgs/String JSON summary (10 Hz)
Coordinate frame
All poses are expressed in camera_color_optical_frame (ROS optical convention):
+X = right, +Y = down, +Z = forward (into scene)
The dock_target pose gives the docking controller everything it needs:
position.z forward distance to dock (m) throttle target
position.x lateral offset (m) steer target
orientation marker orientation for final align yaw servo
Dock target selection
dock_marker_ids list[int] [] empty = accept any detected marker
non-empty = only these IDs are candidates
closest_wins bool true among candidates, prefer the nearest
max_dock_range_m float 3.0 ignore markers beyond this distance
estimatePoseSingleMarkers API
Uses cv2.aruco.estimatePoseSingleMarkers (legacy OpenCV API still present in
4.x with a deprecation notice). Falls back to per-marker cv2.solvePnP with
SOLVEPNP_IPPE_SQUARE if the legacy function is unavailable.
Both paths use the same camera_matrix / dist_coeffs from CameraInfo.
Subscribes
/camera/color/image_raw sensor_msgs/Image 30 Hz (BGR8)
/camera/color/camera_info sensor_msgs/CameraInfo (latched, once)
Publishes
/saltybot/aruco/markers geometry_msgs/PoseArray
/saltybot/aruco/dock_target geometry_msgs/PoseStamped
/saltybot/aruco/viz visualization_msgs/MarkerArray
/saltybot/aruco/status std_msgs/String (JSON, 10 Hz)
Parameters (see config/aruco_detect_params.yaml)
marker_size_m float 0.10 physical side length of printed markers (m)
dock_marker_ids int[] [] IDs to accept as dock targets (empty = all)
max_dock_range_m float 3.0 ignore candidates beyond this (m)
aruco_dict str DICT_4X4_50
corner_refinement str CORNER_REFINE_SUBPIX
camera_frame str camera_color_optical_frame
draw_debug_image bool false publish annotated image for debugging
debug_image_topic str /saltybot/aruco/debug_image
"""
from __future__ import annotations
import json
import math
import rclpy
from rclpy.node import Node
from rclpy.qos import (
QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
)
import numpy as np
import cv2
from cv_bridge import CvBridge
from geometry_msgs.msg import (
Pose, PoseArray, PoseStamped, Point, Quaternion, Vector3, TransformStamped
)
from sensor_msgs.msg import Image, CameraInfo
from std_msgs.msg import Header, String, ColorRGBA
from visualization_msgs.msg import Marker, MarkerArray
from .aruco_math import MarkerPose, rvec_to_quat, tvec_distance
# ── QoS ───────────────────────────────────────────────────────────────────────
_SENSOR_QOS = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=5,
)
_LATCHED_QOS = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST,
depth=1,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
# ArUco axis length for viz (fraction of marker size)
_AXIS_LEN_FRAC = 0.5
class ArucoDetectNode(Node):
"""ArUco marker detection + pose estimation node — see module docstring."""
def __init__(self) -> None:
super().__init__('aruco_detect')
# ── Parameters ────────────────────────────────────────────────────────
self.declare_parameter('marker_size_m', 0.10)
self.declare_parameter('dock_marker_ids', []) # empty list = all
self.declare_parameter('max_dock_range_m', 3.0)
self.declare_parameter('aruco_dict', 'DICT_4X4_50')
self.declare_parameter('corner_refinement', 'CORNER_REFINE_SUBPIX')
self.declare_parameter('camera_frame', 'camera_color_optical_frame')
self.declare_parameter('draw_debug_image', False)
self.declare_parameter('debug_image_topic', '/saltybot/aruco/debug_image')
self._marker_size = self.get_parameter('marker_size_m').value
self._dock_ids = set(self.get_parameter('dock_marker_ids').value)
self._max_range = self.get_parameter('max_dock_range_m').value
self._cam_frame = self.get_parameter('camera_frame').value
self._draw_debug = self.get_parameter('draw_debug_image').value
self._debug_topic = self.get_parameter('debug_image_topic').value
aruco_dict_name = self.get_parameter('aruco_dict').value
refine_name = self.get_parameter('corner_refinement').value
# ── ArUco detector setup ──────────────────────────────────────────────
dict_id = getattr(cv2.aruco, aruco_dict_name, cv2.aruco.DICT_4X4_50)
params = cv2.aruco.DetectorParameters()
# Apply corner refinement
refine_id = getattr(cv2.aruco, refine_name, cv2.aruco.CORNER_REFINE_SUBPIX)
params.cornerRefinementMethod = refine_id
aruco_dict = cv2.aruco.getPredefinedDictionary(dict_id)
self._detector = cv2.aruco.ArucoDetector(aruco_dict, params)
# Object points for solvePnP fallback (counter-clockwise from top-left)
half = self._marker_size / 2.0
self._obj_pts = np.array([
[-half, half, 0.0],
[ half, half, 0.0],
[ half, -half, 0.0],
[-half, -half, 0.0],
], dtype=np.float64)
# ── Camera intrinsics (set on first CameraInfo) ───────────────────────
self._K: np.ndarray | None = None
self._dist: np.ndarray | None = None
self._bridge = CvBridge()
# ── Publishers ─────────────────────────────────────────────────────────
self._pose_array_pub = self.create_publisher(
PoseArray, '/saltybot/aruco/markers', 10
)
self._dock_pub = self.create_publisher(
PoseStamped, '/saltybot/aruco/dock_target', 10
)
self._viz_pub = self.create_publisher(
MarkerArray, '/saltybot/aruco/viz', 10
)
self._status_pub = self.create_publisher(
String, '/saltybot/aruco/status', 10
)
if self._draw_debug:
self._debug_pub = self.create_publisher(
Image, self._debug_topic, 5
)
else:
self._debug_pub = None
# ── Subscriptions ──────────────────────────────────────────────────────
self.create_subscription(
CameraInfo,
'/camera/color/camera_info',
self._on_camera_info,
_LATCHED_QOS,
)
self.create_subscription(
Image,
'/camera/color/image_raw',
self._on_image,
_SENSOR_QOS,
)
# ── Status timer (10 Hz) ────────────────────────────────────────────────
self._last_detections: list[MarkerPose] = []
self.create_timer(0.1, self._on_status_timer)
self.get_logger().info(
f'aruco_detect ready — '
f'dict={aruco_dict_name} '
f'marker_size={self._marker_size * 100:.0f}cm '
f'dock_ids={list(self._dock_ids) if self._dock_ids else "any"} '
f'max_range={self._max_range}m'
)
# ── Camera info ───────────────────────────────────────────────────────────
def _on_camera_info(self, msg: CameraInfo) -> None:
if self._K is not None:
return
self._K = np.array(msg.k, dtype=np.float64).reshape(3, 3)
self._dist = np.array(msg.d, dtype=np.float64).reshape(1, -1)
self.get_logger().info(
f'camera_info received — '
f'fx={self._K[0,0]:.1f} fy={self._K[1,1]:.1f}'
)
# ── Image callback ────────────────────────────────────────────────────────
def _on_image(self, msg: Image) -> None:
if self._K is None:
return
# ── Decode ────────────────────────────────────────────────────────────
try:
bgr = self._bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
except Exception as exc:
self.get_logger().warning(f'image decode error: {exc}',
throttle_duration_sec=5.0)
return
# Convert to greyscale for detection (faster, same accuracy)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
stamp = msg.header.stamp
# ── Detect markers ────────────────────────────────────────────────────
corners, ids, _ = self._detector.detectMarkers(gray)
if ids is None or len(ids) == 0:
self._last_detections = []
self._publish_empty(stamp)
return
ids_flat = ids.flatten().tolist()
# ── Pose estimation ───────────────────────────────────────────────────
detections = self._estimate_poses(corners, ids_flat)
# Filter by range
detections = [d for d in detections if d.distance_m <= self._max_range]
self._last_detections = detections
# ── Publish ───────────────────────────────────────────────────────────
self._publish_pose_array(detections, stamp)
self._publish_dock_target(detections, stamp)
self._publish_viz(detections, stamp)
# Debug image
if self._debug_pub is not None:
self._publish_debug(bgr, corners, ids, detections, stamp)
# ── Pose estimation ───────────────────────────────────────────────────────
def _estimate_poses(
self,
corners: list,
ids: list[int],
) -> list[MarkerPose]:
"""
Estimate 6-DOF pose for each detected marker.
Uses estimatePoseSingleMarkers (legacy API, still present in cv2 4.x).
Falls back to per-marker solvePnP(IPPE_SQUARE) if unavailable.
"""
results: list[MarkerPose] = []
# ── Try legacy estimatePoseSingleMarkers ──────────────────────────────
if hasattr(cv2.aruco, 'estimatePoseSingleMarkers'):
try:
rvecs, tvecs, _ = cv2.aruco.estimatePoseSingleMarkers(
corners, self._marker_size, self._K, self._dist
)
for i, mid in enumerate(ids):
rvec = rvecs[i].ravel().astype(np.float64)
tvec = tvecs[i].ravel().astype(np.float64)
results.append(MarkerPose(
marker_id = int(mid),
tvec = tvec,
rvec = rvec,
corners = corners[i].reshape(4, 2).astype(np.float64),
))
return results
except Exception as exc:
self.get_logger().debug(
f'estimatePoseSingleMarkers failed ({exc}), '
'falling back to solvePnP'
)
# ── Fallback: per-marker solvePnP ─────────────────────────────────────
for i, mid in enumerate(ids):
img_pts = corners[i].reshape(4, 2).astype(np.float64)
ok, rvec, tvec = cv2.solvePnP(
self._obj_pts, img_pts,
self._K, self._dist,
flags=cv2.SOLVEPNP_IPPE_SQUARE,
)
if not ok:
continue
results.append(MarkerPose(
marker_id = int(mid),
tvec = tvec.ravel().astype(np.float64),
rvec = rvec.ravel().astype(np.float64),
corners = img_pts,
))
return results
# ── Dock target selection ─────────────────────────────────────────────────
def _select_dock_target(
self,
detections: list[MarkerPose],
) -> MarkerPose | None:
"""Select the closest marker that matches dock_marker_ids filter."""
candidates = [
d for d in detections
if not self._dock_ids or d.marker_id in self._dock_ids
]
if not candidates:
return None
return min(candidates, key=lambda d: d.distance_m)
# ── Publishers ────────────────────────────────────────────────────────────
def _publish_pose_array(
self,
detections: list[MarkerPose],
stamp,
) -> None:
pa = PoseArray()
pa.header.stamp = stamp
pa.header.frame_id = self._cam_frame
for d in detections:
qx, qy, qz, qw = d.quat
pose = Pose()
pose.position = Point(x=float(d.tvec[0]),
y=float(d.tvec[1]),
z=float(d.tvec[2]))
pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
pa.poses.append(pose)
self._pose_array_pub.publish(pa)
def _publish_dock_target(
self,
detections: list[MarkerPose],
stamp,
) -> None:
target = self._select_dock_target(detections)
if target is None:
return
qx, qy, qz, qw = target.quat
ps = PoseStamped()
ps.header.stamp = stamp
ps.header.frame_id = self._cam_frame
ps.pose.position = Point(x=float(target.tvec[0]),
y=float(target.tvec[1]),
z=float(target.tvec[2]))
ps.pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
self._dock_pub.publish(ps)
def _publish_viz(
self,
detections: list[MarkerPose],
stamp,
) -> None:
"""Publish coordinate-axes MarkerArray for each detected marker."""
ma = MarkerArray()
lifetime = _ros_duration(0.2)
# Delete-all
dm = Marker()
dm.action = Marker.DELETEALL
dm.header.stamp = stamp
dm.header.frame_id = self._cam_frame
ma.markers.append(dm)
target = self._select_dock_target(detections)
axis_len = self._marker_size * _AXIS_LEN_FRAC
for idx, d in enumerate(detections):
is_target = (target is not None and d.marker_id == target.marker_id)
cx, cy, cz = float(d.tvec[0]), float(d.tvec[1]), float(d.tvec[2])
qx, qy, qz, qw = d.quat
base_id = idx * 10
# Sphere at marker centre
sph = Marker()
sph.header.stamp = stamp
sph.header.frame_id = self._cam_frame
sph.ns = 'aruco_centers'
sph.id = base_id
sph.type = Marker.SPHERE
sph.action = Marker.ADD
sph.pose.position = Point(x=cx, y=cy, z=cz)
sph.pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
r_s = 0.015 if not is_target else 0.025
sph.scale = Vector3(x=r_s * 2, y=r_s * 2, z=r_s * 2)
sph.color = (
ColorRGBA(r=1.0, g=0.2, b=0.2, a=1.0) if is_target else
ColorRGBA(r=0.2, g=0.8, b=1.0, a=0.9)
)
sph.lifetime = lifetime
ma.markers.append(sph)
# Text label: ID + distance
txt = Marker()
txt.header.stamp = stamp
txt.header.frame_id = self._cam_frame
txt.ns = 'aruco_labels'
txt.id = base_id + 1
txt.type = Marker.TEXT_VIEW_FACING
txt.action = Marker.ADD
txt.pose.position = Point(x=cx, y=cy - 0.08, z=cz)
txt.pose.orientation.w = 1.0
txt.scale.z = 0.06
txt.color = ColorRGBA(r=1.0, g=1.0, b=0.0, a=1.0)
txt.text = f'ID={d.marker_id}\n{d.distance_m:.2f}m'
if is_target:
txt.text += '\n[DOCK]'
txt.lifetime = lifetime
ma.markers.append(txt)
self._viz_pub.publish(ma)
def _publish_empty(self, stamp) -> None:
pa = PoseArray()
pa.header.stamp = stamp
pa.header.frame_id = self._cam_frame
self._pose_array_pub.publish(pa)
self._publish_viz([], stamp)
def _publish_debug(self, bgr, corners, ids, detections, stamp) -> None:
"""Annotate and publish debug image with detected markers drawn."""
debug = bgr.copy()
if ids is not None and len(ids) > 0:
cv2.aruco.drawDetectedMarkers(debug, corners, ids)
# Draw axes for each detection using the estimated poses
for d in detections:
try:
cv2.drawFrameAxes(
debug, self._K, self._dist,
d.rvec.reshape(3, 1),
d.tvec.reshape(3, 1),
self._marker_size * _AXIS_LEN_FRAC,
)
except Exception:
pass
try:
img_msg = self._bridge.cv2_to_imgmsg(debug, encoding='bgr8')
img_msg.header.stamp = stamp
img_msg.header.frame_id = self._cam_frame
self._debug_pub.publish(img_msg)
except Exception:
pass
# ── Status timer ─────────────────────────────────────────────────────────
def _on_status_timer(self) -> None:
detections = self._last_detections
target = self._select_dock_target(detections)
markers_info = [
{
'id': d.marker_id,
'distance_m': round(d.distance_m, 3),
'yaw_deg': round(math.degrees(d.yaw_rad), 2),
'lateral_m': round(d.lateral_m, 3),
'forward_m': round(d.forward_m, 3),
'is_target': target is not None and d.marker_id == target.marker_id,
}
for d in detections
]
status = {
'detected_count': len(detections),
'dock_target_id': target.marker_id if target else None,
'dock_distance_m': round(target.distance_m, 3) if target else None,
'dock_yaw_deg': round(math.degrees(target.yaw_rad), 2) if target else None,
'dock_lateral_m': round(target.lateral_m, 3) if target else None,
'markers': markers_info,
}
msg = String()
msg.data = json.dumps(status)
self._status_pub.publish(msg)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _ros_duration(seconds: float):
from rclpy.duration import Duration
sec = int(seconds)
nsec = int((seconds - sec) * 1e9)
return Duration(seconds=sec, nanoseconds=nsec).to_msg()
def main(args=None) -> None:
rclpy.init(args=args)
node = ArucoDetectNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,156 @@
"""
aruco_math.py Pure-math helpers for ArUco detection (Issue #627).
No ROS2 dependencies importable in unit tests without a ROS2 install.
Provides:
rot_mat_to_quat(R) (qx, qy, qz, qw)
rvec_to_quat(rvec) (qx, qy, qz, qw) [via cv2.Rodrigues]
tvec_distance(tvec) float (Euclidean norm)
tvec_yaw_rad(tvec) float (atan2(tx, tz) lateral bearing error)
MarkerPose dataclass per-marker detection result
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
import numpy as np
# ── Rotation helpers ──────────────────────────────────────────────────────────
def rot_mat_to_quat(R: np.ndarray) -> Tuple[float, float, float, float]:
"""
Convert a 3×3 rotation matrix to quaternion (qx, qy, qz, qw).
Uses Shepperd's method for numerical stability.
R must be a valid rotation matrix (R = 1, det R = +1).
"""
R = np.asarray(R, dtype=np.float64)
trace = R[0, 0] + R[1, 1] + R[2, 2]
if trace > 0.0:
s = 0.5 / math.sqrt(trace + 1.0)
w = 0.25 / s
x = (R[2, 1] - R[1, 2]) * s
y = (R[0, 2] - R[2, 0]) * s
z = (R[1, 0] - R[0, 1]) * s
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
s = 2.0 * math.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2])
w = (R[2, 1] - R[1, 2]) / s
x = 0.25 * s
y = (R[0, 1] + R[1, 0]) / s
z = (R[0, 2] + R[2, 0]) / s
elif R[1, 1] > R[2, 2]:
s = 2.0 * math.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2])
w = (R[0, 2] - R[2, 0]) / s
x = (R[0, 1] + R[1, 0]) / s
y = 0.25 * s
z = (R[1, 2] + R[2, 1]) / s
else:
s = 2.0 * math.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1])
w = (R[1, 0] - R[0, 1]) / s
x = (R[0, 2] + R[2, 0]) / s
y = (R[1, 2] + R[2, 1]) / s
z = 0.25 * s
# Normalise
norm = math.sqrt(x * x + y * y + z * z + w * w)
if norm < 1e-10:
return 0.0, 0.0, 0.0, 1.0
inv = 1.0 / norm
return x * inv, y * inv, z * inv, w * inv
def rvec_to_quat(rvec: np.ndarray) -> Tuple[float, float, float, float]:
"""
Convert a Rodrigues rotation vector (3,) or (1,3) to quaternion.
Requires cv2 for cv2.Rodrigues(); falls back to manual exponential map
if cv2 is unavailable.
"""
vec = np.asarray(rvec, dtype=np.float64).ravel()
try:
import cv2
R, _ = cv2.Rodrigues(vec)
except ImportError:
# Manual Rodrigues exponential map
angle = float(np.linalg.norm(vec))
if angle < 1e-12:
return 0.0, 0.0, 0.0, 1.0
axis = vec / angle
K = np.array([
[ 0.0, -axis[2], axis[1]],
[ axis[2], 0.0, -axis[0]],
[-axis[1], axis[0], 0.0],
])
R = np.eye(3) + math.sin(angle) * K + (1.0 - math.cos(angle)) * K @ K
return rot_mat_to_quat(R)
# ── Metric helpers ────────────────────────────────────────────────────────────
def tvec_distance(tvec: np.ndarray) -> float:
"""Euclidean distance from camera to marker (m)."""
t = np.asarray(tvec, dtype=np.float64).ravel()
return float(math.sqrt(t[0] ** 2 + t[1] ** 2 + t[2] ** 2))
def tvec_yaw_rad(tvec: np.ndarray) -> float:
"""
Horizontal bearing error to marker (rad).
atan2(tx, tz): +ve when marker is to the right of the camera optical axis.
Zero when the marker is directly in front.
"""
t = np.asarray(tvec, dtype=np.float64).ravel()
return float(math.atan2(t[0], t[2]))
# ── Per-marker result ─────────────────────────────────────────────────────────
@dataclass
class MarkerPose:
"""Pose result for a single detected ArUco marker."""
marker_id: int
tvec: np.ndarray # (3,) translation in camera optical frame (m)
rvec: np.ndarray # (3,) Rodrigues rotation vector
corners: np.ndarray # (4, 2) image-plane corner coordinates (px)
# Derived (computed lazily)
_distance_m: Optional[float] = field(default=None, repr=False)
_yaw_rad: Optional[float] = field(default=None, repr=False)
_quat: Optional[Tuple[float, float, float, float]] = field(default=None, repr=False)
@property
def distance_m(self) -> float:
if self._distance_m is None:
self._distance_m = tvec_distance(self.tvec)
return self._distance_m
@property
def yaw_rad(self) -> float:
if self._yaw_rad is None:
self._yaw_rad = tvec_yaw_rad(self.tvec)
return self._yaw_rad
@property
def lateral_m(self) -> float:
"""Lateral offset (m); +ve = marker is to the right."""
return float(self.tvec[0])
@property
def forward_m(self) -> float:
"""Forward distance (Z component in camera frame, m)."""
return float(self.tvec[2])
@property
def quat(self) -> Tuple[float, float, float, float]:
"""Quaternion (qx, qy, qz, qw) from rvec."""
if self._quat is None:
self._quat = rvec_to_quat(self.rvec)
return self._quat

View File

@ -0,0 +1,5 @@
[develop]
script_dir=$base/lib/saltybot_aruco_detect
[install]
install_scripts=$base/lib/saltybot_aruco_detect

View File

@ -0,0 +1,30 @@
import os
from glob import glob
from setuptools import setup
package_name = 'saltybot_aruco_detect'
setup(
name=package_name,
version='0.1.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='sl-perception',
maintainer_email='sl-perception@saltylab.local',
description='ArUco marker detection for docking alignment (Issue #627)',
license='MIT',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'aruco_detect = saltybot_aruco_detect.aruco_detect_node:main',
],
},
)

View File

@ -0,0 +1,206 @@
"""Unit tests for aruco_math.py — no ROS2, no live camera required."""
import math
import numpy as np
import pytest
from saltybot_aruco_detect.aruco_math import (
rot_mat_to_quat,
rvec_to_quat,
tvec_distance,
tvec_yaw_rad,
MarkerPose,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _quat_norm(q):
return math.sqrt(sum(x * x for x in q))
def _quat_rotate(q, v):
"""Rotate vector v by quaternion q (qx, qy, qz, qw)."""
qx, qy, qz, qw = q
# Quaternion product: q * (0,v) * q^-1
# Using formula: v' = v + 2qw(q × v) + 2(q × (q × v))
qvec = np.array([qx, qy, qz])
t = 2.0 * np.cross(qvec, v)
return v + qw * t + np.cross(qvec, t)
# ── rot_mat_to_quat ───────────────────────────────────────────────────────────
def test_identity_rotation():
R = np.eye(3)
q = rot_mat_to_quat(R)
assert abs(q[3] - 1.0) < 1e-9 # w = 1 for identity
assert abs(q[0]) < 1e-9
assert abs(q[1]) < 1e-9
assert abs(q[2]) < 1e-9
def test_90deg_yaw():
"""90° rotation about Z axis."""
angle = math.pi / 2
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
qx, qy, qz, qw = rot_mat_to_quat(R)
# For 90° Z rotation: q = (0, 0, sin45°, cos45°)
assert abs(qx) < 1e-6
assert abs(qy) < 1e-6
assert abs(qz - math.sin(angle / 2)) < 1e-6
assert abs(qw - math.cos(angle / 2)) < 1e-6
def test_unit_norm():
for yaw in [0, 0.5, 1.0, 2.0, math.pi]:
R = np.array([
[math.cos(yaw), -math.sin(yaw), 0],
[math.sin(yaw), math.cos(yaw), 0],
[0, 0, 1],
])
q = rot_mat_to_quat(R)
assert abs(_quat_norm(q) - 1.0) < 1e-9, f"yaw={yaw}: norm={_quat_norm(q)}"
def test_roundtrip_rotation_matrix():
"""Rotating a vector with the matrix and quaternion should give same result."""
angle = 1.23
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
q = rot_mat_to_quat(R)
v = np.array([1.0, 0.0, 0.0])
v_R = R @ v
v_q = _quat_rotate(q, v)
np.testing.assert_allclose(v_R, v_q, atol=1e-9)
def test_180deg_pitch():
"""180° rotation about Y axis."""
R = np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]], dtype=float)
q = rot_mat_to_quat(R)
assert abs(_quat_norm(q) - 1.0) < 1e-9
# For 180° Y: q = (0, 1, 0, 0) or (0, -1, 0, 0)
assert abs(abs(q[1]) - 1.0) < 1e-6
# ── rvec_to_quat ──────────────────────────────────────────────────────────────
def test_rvec_zero_is_identity():
q = rvec_to_quat(np.array([0.0, 0.0, 0.0]))
assert abs(_quat_norm(q) - 1.0) < 1e-6
assert abs(q[3] - 1.0) < 1e-6 # w = 1
def test_rvec_unit_norm():
for rvec in [
[0.1, 0.0, 0.0],
[0.0, 0.5, 0.0],
[0.0, 0.0, 1.57],
[0.3, 0.4, 0.5],
]:
q = rvec_to_quat(np.array(rvec))
assert abs(_quat_norm(q) - 1.0) < 1e-9, f"rvec={rvec}: norm={_quat_norm(q)}"
def test_rvec_z_rotation_matches_rot_mat():
"""rvec=[0,0,π/3] should match 60° Z rotation matrix quaternion."""
angle = math.pi / 3
rvec = np.array([0.0, 0.0, angle])
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
q_rvec = rvec_to_quat(rvec)
q_R = rot_mat_to_quat(R)
# Allow sign flip (q and -q represent the same rotation)
diff = min(
abs(_quat_norm(np.array(q_rvec) - np.array(q_R))),
abs(_quat_norm(np.array(q_rvec) + np.array(q_R))),
)
assert diff < 1e-6, f"rvec q={q_rvec}, mat q={q_R}"
# ── tvec_distance ─────────────────────────────────────────────────────────────
def test_tvec_distance_basic():
assert abs(tvec_distance(np.array([3.0, 4.0, 0.0])) - 5.0) < 1e-9
def test_tvec_distance_zero():
assert tvec_distance(np.array([0.0, 0.0, 0.0])) == 0.0
def test_tvec_distance_forward():
assert abs(tvec_distance(np.array([0.0, 0.0, 2.5])) - 2.5) < 1e-9
def test_tvec_distance_3d():
t = np.array([1.0, 2.0, 3.0])
expected = math.sqrt(14.0)
assert abs(tvec_distance(t) - expected) < 1e-9
# ── tvec_yaw_rad ──────────────────────────────────────────────────────────────
def test_yaw_zero_when_centred():
"""Marker directly in front: tx=0 → yaw=0."""
assert abs(tvec_yaw_rad(np.array([0.0, 0.0, 2.0]))) < 1e-9
def test_yaw_positive_right():
"""Marker to the right (tx>0) → positive yaw."""
assert tvec_yaw_rad(np.array([1.0, 0.0, 2.0])) > 0.0
def test_yaw_negative_left():
"""Marker to the left (tx<0) → negative yaw."""
assert tvec_yaw_rad(np.array([-1.0, 0.0, 2.0])) < 0.0
def test_yaw_45deg():
"""tx=tz → 45°."""
assert abs(tvec_yaw_rad(np.array([1.0, 0.0, 1.0])) - math.pi / 4) < 1e-9
# ── MarkerPose ────────────────────────────────────────────────────────────────
def _make_marker(mid=42, tx=0.0, ty=0.0, tz=2.0):
rvec = np.array([0.0, 0.0, 0.1])
tvec = np.array([tx, ty, tz])
corners = np.zeros((4, 2))
return MarkerPose(marker_id=mid, tvec=tvec, rvec=rvec, corners=corners)
def test_marker_distance_cached():
m = _make_marker(tz=3.0)
d1 = m.distance_m
d2 = m.distance_m
assert d1 == d2
def test_marker_lateral_and_forward():
m = _make_marker(tx=0.5, tz=2.0)
assert abs(m.lateral_m - 0.5) < 1e-9
assert abs(m.forward_m - 2.0) < 1e-9
def test_marker_quat_unit_norm():
m = _make_marker()
q = m.quat
assert abs(_quat_norm(q) - 1.0) < 1e-9
def test_marker_yaw_sign():
m_right = _make_marker(tx=+0.3, tz=1.0)
m_left = _make_marker(tx=-0.3, tz=1.0)
assert m_right.yaw_rad > 0
assert m_left.yaw_rad < 0

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