Compare commits

...

4 Commits

Author SHA1 Message Date
e4116dffc0 feat: Remove ELRS arm requirement for autonomous operation (Issue #512)
Enable Jetson autonomous arming while keeping RC as optional override.

Changes:
- RC kill switch (CH5 OFF) now triggers emergency stop instead of disarm
  → Allows Jetson-armed robots to remain armed when RC disconnects
  → Maintains kill switch safety for emergency situations

- RC disarm only triggers on explicit CH5 falling edge (RC still alive)
  → RC disconnect doesn't disarm Jetson-controlled missions
  → RC failsafe timer (500ms) handles signal loss separately

- Jetson arming via CDC 'A' command works independently of RC state
  → Robots can operate fully autonomous without RC transmitter
  → Heartbeat timeout (500ms) prevents runaway if Jetson crashes

Safety maintained:
- Arming hold timer: 500ms (prevents accidental arm)
- Tilt limit: ±10° level required
- IMU calibration: Required before any arm attempt
- Remote E-stop: Blocks all arming
- RC failsafe: 500ms signal loss = disarm
- Jetson timeout: 500ms heartbeat = zero motors

Command protocol (unchanged):
- Jetson: A=arm, D=disarm, E=estop, Z=clear estop
- RC: CH5 switch (optional override)
- Heartbeat: H command every ≤500ms
- Drive: C<speed>,<steer> every ≤200ms

See AUTONOMOUS_ARMING.md for complete protocol and testing checklist.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 11:45:12 -05:00
b0c2b5564d feat: Add Issue #505 CAD - 24V Charging Dock OpenSCAD Models
CAD implementation files for Issue #505 (24V charging dock upgrade):

- charging_dock_505.scad: Main dock assembly
  * Base plate: 340×320×12 mm (enlarged for 240W PSU)
  * Back wall: 250×85×10 mm (pogo pin housing, LED bezel recess)
  * V-guide rails: 100mm deep, self-centering funnel (print 2×)
  * ArUco marker frame: ID 42 (DICT_4X4_250), 15cm mast
  * PSU bracket: Sized for Mean Well IRM-240-24 (210×108×56 mm)
  * LED bezel: 4× status indicators (SEARCHING/ALIGNED/CHARGING/FULL)

- charging_dock_receiver_505.scad: Robot-side receiver variants
  * Lab receiver: Stem collar mount (SaltyLab)
  * Rover receiver: Deck flange mount (SaltyRover)
  * Tank receiver: Skid plate mount + extended nose (SaltyTank)
  * Common contact geometry: 20mm CL-to-CL brass pads, V-nose guide
  * Wire bore: 3mm (supports 12 AWG charging wires)

Key changes from Issue #159 (5V):
- PSU dimensions: 63×45×28 mm → 210×108×56 mm
- Base/bracket enlarged accordingly
- ArUco ID: 0 → 42
- Contact geometry unchanged (compatible with Issue #159 receivers)
- Pogo pins, V-guides, LED circuit identical

Files ready for:
- STL export via OpenSCAD render commands
- 3D printing (PETG recommended)
- Assembly integration with docking node (#489)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 11:44:28 -05:00
6f3dd46285 feat: Add Issue #503 - Audio pipeline with Jabra SPEAK 810
Implement full audio pipeline with:
- Jabra SPEAK 810 USB audio I/O (mic + speaker)
- openwakeword 'Hey Salty' wake word detection
- whisper.cpp GPU-accelerated STT (small/base/medium/large models)
- piper TTS synthesis and playback
- Audio state machine: listening → processing → speaking
- MQTT status and state reporting
- Real-time latency metrics tracking

ROS2 Topics Published:
- /saltybot/speech/transcribed_text: STT output for voice router
- /saltybot/audio/state: Current audio state
- /saltybot/audio/status: JSON metrics with latencies

MQTT Topics:
- saltybot/audio/state: Current state (listening/processing/speaking)
- saltybot/audio/status: Complete status JSON

Configuration parameters in yaml:
- device_name: Jabra device pattern
- wake_word_threshold: 0.5 (tunable)
- whisper_model: small/base/medium/large
- mqtt_enabled: true/false with broker config

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 10:30:58 -05:00
5cea0812d5 feat: Add Issue #505 - 24V Charging Dock Hardware Design
- Design specification: 24V DC power delivery (upgraded from 5V Issue #159)
- ArUco marker ID 42 (15cm frame) for precision alignment
- Spring-loaded contact pads with V-channel guide rails
- Comprehensive BOM for 24V PSU, wiring, LED status circuit
- Compatible with docking node #489 (ROS2 integration)
- 3D-printable PETG frame (base, back wall, guide rails, brackets)
- Electrical: 240W Mean Well IRM-240-24 PSU, 20A current capacity
- Safety: Fused output, varistor protection, soft-start capable
- Integration: MQTT status reporting, GPIO LED control (Jetson Orin NX)

Files:
- ISSUE_505_CHARGING_DOCK_24V_DESIGN.md: Complete design spec (mechanical, electrical, assembly)
- charging_dock_505_BOM.csv: Procurement list with sourcing info

Next: CAD implementation (charging_dock_505.scad, receiver variant)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 10:30:44 -05:00
19 changed files with 3084 additions and 7 deletions

136
AUTONOMOUS_ARMING.md Normal file
View File

@ -0,0 +1,136 @@
# Autonomous Arming (Issue #512)
## Overview
The robot can now be armed and operated autonomously from the Jetson without requiring an RC transmitter. The RC receiver (ELRS) is now optional and serves as an override/kill-switch rather than a requirement.
## Arming Sources
### Jetson Autonomous Arming
- Command: `A\n` (single byte 'A' followed by newline)
- Sent via USB CDC to the STM32 firmware
- Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
- Works even when RC is not connected or not armed
### RC Arming (Optional Override)
- Command: CH5 switch on ELRS transmitter
- When RC is connected and armed, robot can be armed via RC
- RC and Jetson can both request arming independently
## Safety Features
### Maintained from Original Design
1. **Arming Hold Timer** — 500ms hold before motors enable (prevents accidental arming)
2. **Tilt Safety** — Robot must be within ±10° level to arm
3. **IMU Calibration** — Gyro must be calibrated before arming
4. **Remote E-Stop Override**`safety_remote_estop_active()` blocks all arming
### New for Autonomous Operation
1. **RC Kill Switch** (CH5 OFF when RC connected)
- Triggers emergency stop (motor cutoff) instead of disarm
- Allows Jetson-armed robots to remain armed when RC disconnects
- Maintains safety of kill switch for emergency situations
2. **RC Failsafe**
- If RC signal is lost after being established, robot disarms (500ms timeout)
- Prevents runaway if RC connection drops during flight
- USB-only mode (no RC ever connected) is unaffected
3. **Jetson Timeout** (200ms heartbeat)
- Jetson must send heartbeat (H command) every 500ms
- Prevents autonomous runaway if Jetson crashes/loses connection
- Handled by `jetson_cmd_is_active()` checks
## Command Protocol
### From Jetson to STM32 (USB CDC)
```
A — Request arm (triggers safety hold, then motors enable)
D — Request disarm (immediate motor stop)
E — Emergency stop (immediate motor cutoff, latched)
Z — Clear emergency stop latch
H — Heartbeat (refresh timeout timer, every 500ms)
C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat)
```
### From STM32 to Jetson (USB CDC)
Motor commands are gated by `bal.state == BALANCE_ARMED`:
- When ARMED: Motor commands sent every 20ms (50 Hz)
- When DISARMED: Zero sent every 20ms (prevents ESC timeout)
## Arming State Machine
```
DISARMED
+-- Jetson sends 'A' OR RC CH5 rises (with conditions met)
safety_arm_start() called
(arm hold timer starts)
Wait ARMING_HOLD_MS
safety_arm_ready() returns true
balance_arm() called
ARMED ← (motors now respond to commands)
ARMED
+-- Jetson sends 'D' → balance_disarm()
+-- RC CH5 falls AND RC still alive → balance_disarm()
+-- RC signal lost (failsafe) → balance_disarm()
+-- Tilt fault detected → immediate motor stop
+-- RC kill switch (CH5 OFF) → emergency stop (not disarm)
```
## RC Override Priority
When RC is connected and active:
- **Steer channel**: Blended with Jetson via `mode_manager` (per active mode)
- **Kill switch**: RC CH5 OFF triggers emergency stop (overrides everything)
- **Failsafe**: RC signal loss triggers disarm (prevents runaway)
When RC is disconnected:
- Robot operates under Jetson commands alone
- Emergency stop remains available via 'E' command from Jetson
- No automatic mode change; mission continues autonomously
## Testing Checklist
- [ ] Jetson can arm robot without RC (send 'A' command)
- [ ] Robot motors respond to Jetson drive commands when armed
- [ ] Robot disarms on Jetson 'D' command
- [ ] RC kill switch (CH5 OFF) triggers emergency stop without disarming
- [ ] Robot can be re-armed after RC kill switch via Jetson 'A' command
- [ ] RC failsafe still works (500ms signal loss = disarm)
- [ ] Jetson heartbeat timeout works (500ms without H/C = motors zero)
- [ ] Tilt fault still triggers immediate stop
- [ ] IMU calibration required before arm
- [ ] Arming hold timer (500ms) enforced
## Migration from RC-Only
### Old Workflow (ELRS-Required)
1. Power on robot
2. Arm via RC CH5
3. Send speed/steer commands via RC
4. Disarm via RC CH5
### New Workflow (Autonomous)
1. Power on robot
2. Send heartbeat 'H' every 500ms from Jetson
3. When ready to move, send 'A' command (wait 500ms)
4. Send drive commands 'C<spd>,<str>' every ≤200ms
5. When done, send 'D' command to disarm
### New Workflow (RC + Autonomous Mixed)
1. Power on robot, bring up RC
2. Jetson sends heartbeat 'H'
3. Arm via RC CH5 OR Jetson 'A' (both valid)
4. Control via RC sticks OR Jetson drive commands (blended)
5. Emergency kill: RC CH5 OFF (emergency stop) OR Jetson 'E'
6. Disarm: RC CH5 OFF then ON, OR Jetson 'D'
## References
- Issue #512: Remove ELRS arm requirement
- Files: `/src/main.c` (arming logic), `/lib/USB_CDC/src/usbd_cdc_if.c` (CDC commands)

View File

@ -0,0 +1,619 @@
# Issue #505: 24V Charging Dock Hardware Design
**Agent:** sl-mechanical
**Status:** In Progress
**Date Started:** 2026-03-06
**Related Issues:** #159 (5V dock), #489 (docking node)
---
## Design Overview
Upgraded charging dock system for 24V DC power delivery with improved reliability, higher power capacity, and integrated ArUco marker (ID 42) for precision alignment.
### Key Specifications
| Parameter | Specification | Notes |
|-----------|---------------|-------|
| **Voltage** | 24 V DC | Upgrade from 5V (Issue #159) |
| **Power capacity** | 480 W (20 A @ 24V) | Supports battery charging + auxiliary systems |
| **Contact type** | Spring-loaded brass pads (Ø12 mm, 2 pads) | 20 mm CL-to-CL spacing |
| **Alignment method** | V-channel rails + ArUco marker ID 42 | Precision ±15 mm tolerance |
| **Docking nodes** | Compatible with Issue #489 (ROS2 docking node) | MQTT status reporting |
| **Frame material** | PETG (3D-printable) | All parts exportable as STL |
| **Contact height** | 35 mm above dock floor (configurable per robot) | Same as Issue #159 |
---
## Subsystem Design
### A. Power Distribution
#### PSU Selection (24V upgrade)
**Primary:** Mean Well IRM-240-24 or equivalent
- 240W / 10A @ 24V, open frame
- Input: 100-240V AC 50/60Hz
- Output: 24V ±5% regulated
- Recommended alternatives:
- HLK-240M24 (Hi-Link, 240W, compact)
- RECOM R-120-24 (half-power option, 120W)
- TDK-Lambda DRB-240-24 (industrial grade)
**Specifications:**
- PCB-mount or chassis-mount (via aluminum bracket)
- 2× PG7 cable glands for AC input + 24V output
- Thermal shutdown at 70°C (add heatsink if needed)
#### Power Delivery Cables
| Component | Spec | Notes |
|-----------|------|-------|
| PSU to pogo pins | 12 AWG silicone wire (red/black) | 600V rated, max 20A |
| Cable gland exits | PG7, M20 thread, 5-8 mm cable | IP67 rated |
| Strain relief | Silicone sleeve, 5 mm ID | 150 mm sections at terminations |
| Crimp terminals | M3/M4 ring lug, 12 AWG | Solder + crimped (both) |
#### Contact Resistance & Safety
- **Target contact resistance:** <50 (brass pad to pogo pin)
- **Transient voltage suppression:** Varistor (MOV) across 24V rail (14-28V clamping)
- **Inrush current limiting:** NTC thermistor (10Ω @ 25°C) or soft-start relay
- **Over-current protection:** 25A fuse (slow-blow) on PSU output
---
### B. Mechanical Structure
#### Dock Base Plate
**Material:** PETG (3D-printed)
**Dimensions:** 300 × 280 × 12 mm (L×W×H)
**Ballast:** 8× M20 hex nuts (4 pockets, 2 nuts per pocket) = ~690 g stabilization
**Features:**
- 4× M4 threaded inserts (deck mounting)
- 4× ballast pockets (underside, 32×32×8 mm each)
- Wiring channel routing (10×10 mm), PSU mounting rails
- Cable exit slot with strain relief
#### Back Wall / Pogo Housing
**Material:** PETG
**Dimensions:** 250 × 85 × 10 mm (W×H×T)
**Contact face:** 2× pogo pin bores (Ø5.7 mm, 20 mm deep)
**Features:**
- Pogo pin spring pre-load: 4 mm travel (contact engage at ~3 mm approach)
- LED status bezel mount (4× 5 mm LED holes)
- Smooth contact surface (0.4 mm finish to reduce arcing)
#### V-Guide Rails (Left & Right)
**Material:** PETG
**Function:** Self-aligning funnel for robot receiver plate
**Geometry:**
- V-channel depth: 15 mm (±7.5 mm from centerline)
- Channel angle: 60° (Vee angle) for self-centering
- Guide length: 250 mm (front edge to back wall)
- 2.5 mm wall thickness (resists impact deformation)
**Design goal:** Robot can approach ±20 mm off-center; V-rails funnel it to ±5 mm at dock contact.
#### ArUco Marker Frame
**Design:** 15 cm × 15 cm frame (150×150 mm outer), marker ID 42
**Frame mounting:**
- Material: PETG (3D-printed frame + acrylic cover)
- Marker insertion: Side-slot, captures 100×100 mm laminated ArUco label
- Position: Dock entrance, 1.5 m height for camera visibility
- Lighting: Optional white LED ring around frame for contrast
**Marker specs:**
- Dictionary: `cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)`
- Marker ID: 42 (uint8, fits DICT_4X4_250: 0-249)
- Printed size: 100×100 mm
- Media: Glossy photo paper + 80 µm lamination (weather protection)
#### PSU Bracket
**Material:** PETG
**Attachment:** 4× M4 SHCS to base rear, bolts through PSU flanges
**Features:**
- Mounting pads for PSU feet
- Cable routing guides (AC input + 24V output)
- Thermal airflow clearance (30 mm minimum)
- Optional DIN-rail adapter (for rackmount variant)
#### LED Status Bezel
**Material:** PETG
**Function:** 4× LED indicator display (charging state feedback)
**LEDs & Resistors:**
| LED | Color | State | Vf (typ) | Resistor | Notes |
|-----|-------|-------|----------|----------|-------|
| L1 | Red | SEARCHING | 2.0 V | 180 Ω | No robot contact |
| L2 | Yellow | ALIGNED | 2.1 V | 180 Ω | Contact made, BMS pre-charge |
| L3 | Blue | CHARGING | 3.2 V | 100 Ω | Active charging |
| L4 | Green | FULL | 2.1 V | 180 Ω | Trickle/float mode |
**Current calculation (for 24V rail):**
- Red/Yellow/Green: R = (24 Vf) / 0.020 ≈ 1000 Ω (use 1.0 kΩ 1/4W)
- Blue: R = (24 3.2) / 0.020 = 1040 Ω (use 1.0 kΩ)
**Control:**
- Jetson Orin NX GPIO output (via I2C LED driver or direct GPIO)
- Pulldown resistor (10 kΩ) on each GPIO if using direct drive
- Alternative: TP4056 analog output pins (if in feedback path)
---
### C. Robot Receiver (Mating Interface)
**Cross-variant compliance:** Same receiver design works for SaltyLab, SaltyRover, SaltyTank with different mounting interfaces.
#### Contact Pads
- **Material:** Bare brass (10-12 mm OD, 2 mm thick)
- **Pressing:** 0.1 mm interference fit into PETG housing
- **Polarity marking:** "+" slot on right side (+X), "-" unmarked on left
- **Solder lug:** M3 ring lug on rear face (connects to robot BMS)
#### V-Nose Guide
- **Profile:** Chamfered 14° V-nose (30 mm wide)
- **Function:** Mates with dock V-rails for alignment funnel
#### Mounting Variants
| Robot | Mount Type | Fastener | Height Adjustment |
|-------|-----------|----------|------------------|
| SaltyLab | Stem collar (split, 2×) | M4 × 16 SHCS (2×) | Tune via firmware offset |
| SaltyRover | Deck flange (bolt-on) | M4 × 16 SHCS (4×) | 20 mm shim if needed |
| SaltyTank | Skid plate (bolt-on) | M4 × 16 SHCS (4×) | 55 mm ramp shim recommended |
---
## 3D-Printable Parts (STL Exports)
All parts print in PETG, 0.2 mm layer height, 40-60% infill:
| Part | File | Qty | Infill | Est. Mass | Notes |
|------|------|-----|--------|----------|-------|
| Dock base | `charging_dock_505.scad` (base_stl) | 1 | 60% | ~420 g | Print on large bed (300×280 mm) |
| Back wall + pogo | `charging_dock_505.scad` (back_wall_stl) | 1 | 40% | ~140 g | Smooth face finish required |
| V-rail left | `charging_dock_505.scad` (guide_rail_stl) | 1 | 50% | ~65 g | Mirror for right side in slicer |
| V-rail right | *(mirror of left)* | 1 | 50% | ~65 g | — |
| ArUco frame | `charging_dock_505.scad` (aruco_frame_stl) | 1 | 30% | ~35 g | Slot accepts 100×100 mm marker |
| PSU bracket | `charging_dock_505.scad` (psu_bracket_stl) | 1 | 40% | ~45 g | — |
| LED bezel | `charging_dock_505.scad` (led_bezel_stl) | 1 | 40% | ~15 g | — |
| **Receiver (Lab)** | `charging_dock_receiver_505.scad` (lab_stl) | 1 | 60% | ~32 g | Stem collar variant |
| **Receiver (Rover)** | `charging_dock_receiver_505.scad` (rover_stl) | 1 | 60% | ~36 g | Deck flange variant |
| **Receiver (Tank)** | `charging_dock_receiver_505.scad` (tank_stl) | 1 | 60% | ~42 g | Extended nose variant |
---
## Bill of Materials (BOM)
### Electrical Components
#### Power Supply & Wiring
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| E1 | PSU — 24V 10A | Mean Well IRM-240-24 or Hi-Link HLK-240M24 | 1 | ~$4060 | ~$50 | Digi-Key, Amazon |
| E2 | 12 AWG silicone wire | Red + black, 600V rated, 5 m spool | 1 | ~$15 | ~$15 | McMaster-Carr, AliExpress |
| E3 | PG7 cable gland | M20 thread, IP67, 58 mm cable | 2 | ~$3 | ~$6 | AliExpress, Heilind |
| E4 | Varistor (MOV) | 1828V, 1 kA | 1 | ~$1 | ~$1 | Digi-Key |
| E5 | Fuse — 25A | T25 slow-blow, 5×20 mm | 1 | ~$0.50 | ~$0.50 | Digi-Key |
| E6 | Fuse holder | 5×20 mm inline, 20A rated | 1 | ~$2 | ~$2 | Amazon |
| E7 | Crimp ring terminals | M3, 12 AWG, tin-plated | 8 | ~$0.20 | ~$1.60 | Heilind, AliExpress |
| E8 | Strain relief sleeve | 5 mm ID silicone, 1 m | 1 | ~$5 | ~$5 | McMaster-Carr |
#### Pogo Pins & Contacts
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| C1 | Pogo pin assembly | Spring-loaded, Ø5.5 mm OD, 20 mm, 20A rated, 4 mm travel | 2 | ~$812 | ~$20 | Preci-Dip, Jst, AliExpress |
| C2 | Brass contact pad | Ø12 × 2 mm, H68 brass, bare finish | 2 | ~$3 | ~$6 | Metal supplier (Metals USA, OnlineMetals) |
| C3 | Solder lug — M3 | Copper ring, tin-plated | 4 | ~$0.40 | ~$1.60 | Heilind, Amazon |
#### LED Status Circuit
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| L1 | 5 mm LED — Red | 2.0 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| L2 | 5 mm LED — Yellow | 2.1 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| L3 | 5 mm LED — Blue | 3.2 V, 20 mA, diffuse | 1 | ~$0.50 | ~$0.50 | Digi-Key |
| L4 | 5 mm LED — Green | 2.1 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| R1R4 | Resistor — 1 kΩ 1/4W | Metal film, 1% tolerance | 4 | ~$0.10 | ~$0.40 | Digi-Key |
| J1 | Pin header 2.54 mm | 1×6 right-angle | 1 | ~$0.50 | ~$0.50 | Digi-Key |
#### Current Sensing (Optional)
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| S1 | INA219 I2C shunt monitor | 16-bit, I2C addr 0x40, 26V max | 1 | ~$5 | ~$5 | Adafruit, Digi-Key |
| S2 | SMD resistor — 0.1 Ω | 1206, 1W | 1 | ~$1 | ~$1 | Digi-Key |
### Mechanical Hardware
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| M1 | M20 hex nut | Steel DIN 934, ~86 g | 8 | ~$0.80 | ~$6.40 | Grainger, Home Depot |
| M2 | M4 × 16 SHCS | Stainless A4 DIN 912 | 16 | ~$0.30 | ~$4.80 | Grainger |
| M3 | M4 × 10 BHCS | Stainless A4 DIN 7380 | 8 | ~$0.25 | ~$2.00 | Grainger |
| M4 | M4 heat-set insert | Brass, threaded, M4 | 20 | ~$0.15 | ~$3.00 | McMaster-Carr |
| M5 | M3 × 16 SHCS | Stainless, LED bezel | 4 | ~$0.20 | ~$0.80 | Grainger |
| M6 | M3 hex nut | DIN 934 | 4 | ~$0.10 | ~$0.40 | Grainger |
| M7 | M8 × 40 BHCS | Zinc-plated, floor anchors (optional) | 4 | ~$0.50 | ~$2.00 | Grainger |
| M8 | Rubber foot | Ø20 × 5 mm, self-adhesive | 4 | ~$0.80 | ~$3.20 | Amazon |
### ArUco Marker & Frame
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| A1 | ArUco marker print | 100×100 mm, ID=42, DICT_4X4_250, glossy photo paper | 2 | ~$1.50 | ~$3.00 | Print locally or AliExpress |
| A2 | Lamination pouch | A4, 80 µm thick | 2 | ~$0.40 | ~$0.80 | Amazon, Staples |
| A3 | Acrylic cover sheet | Clear, 3 mm, 150×150 mm | 1 | ~$3 | ~$3.00 | McMaster-Carr |
### Consumables & Assembly
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| X1 | Solder wire | 63/37 Sn/Pb or lead-free, 1 m | 1 | ~$3 | ~$3.00 | Digi-Key |
| X2 | Flux paste | No-clean, 25 mL | 1 | ~$4 | ~$4.00 | Digi-Key |
| X3 | Loctite 243 | Thread-locker (medium strength), 10 mL | 1 | ~$4 | ~$4.00 | Grainger |
| X4 | Epoxy adhesive | Two-part, 25 mL | 1 | ~$6 | ~$6.00 | Home Depot |
---
## Assembly Procedure
### Phase 1: Preparation
1. **Print all PETG parts** (see STL export list above)
- Base: 0.3 mm layer, 60% infill (heavy/stable)
- Back wall: 0.2 mm, 40% infill
- Rails & brackets: 0.2 mm, 40-50% infill
- Support removal: slow, avoid pogo bore damage
2. **Prepare ballast nuts**
- Sort 8× M20 hex nuts (stack in 4 pockets, 2 per pocket)
- Optional: fill pockets with epoxy to prevent rattling
3. **Press brass contact pads**
- Apply 0.1 mm interference press-fit into receiver housing bores
- Use arbor press @ ~2 tons force
- Or use slow manual press (avoid chipping brass edges)
### Phase 2: Base Assembly
4. **Install heat-set M4 inserts** into base plate
- Back wall attach points (3×)
- Guide rail attach points (4× each side)
- ArUco mast feet (4×)
- PSU bracket mount (4×)
- Use soldering iron (350°C) or insert tool, press vertically
5. **Ballast installation**
- Insert M20 hex nuts into base pockets (from underside)
- Verify pockets are flush, no protrusions into wiring channel
- Optional: epoxy-lock nuts with 5-minute epoxy
6. **Install pogo pins** into back wall
- Press spring-loaded pins from front face into Ø5.7 mm bores (20 mm deep)
- Flange seats against counterbore shoulder at 1.5 mm depth
- Apply small drop of Loctite 243 to bore wall (prevents rotation)
### Phase 3: Electrical Assembly
7. **Solder wires to pogo pin terminals**
- 12 AWG red wire → POGO+ pin
- 12 AWG black wire → POGO- pin
- Solder both in & out of lug for redundancy
- Add ~50 mm strain relief sleeve over each joint
8. **Route pogo wires through base wiring channel**
- Guide down channel (10×10 mm trough)
- Exit through cable gland slot on rear
9. **Assemble PSU bracket**
- Bolt Mean Well IRM-240-24 (or equivalent) to bracket pads
- 4× M4 fasteners through bracket to base rear
- Orient PSU exhaust away from dock (for ventilation)
10. **Connect 24V wiring**
- Pogo+ wire (red) → PSU V+ terminal
- Pogo- wire (black) → PSU COM/GND terminal
- Observe polarity strictly (reverse = short circuit)
11. **Install power protection**
- Fuse holder in-line on PSU V+ output (25A slow-blow)
- Varistor (MOV, 1828V) across V+/COM rails (clamp transients)
- Optional: NTC thermistor (10Ω @ 25°C) in series for soft-start
12. **Wire AC mains input** (if not pre-assembled)
- Route AC input through cable gland on PSU bracket
- Connect to PSU AC terminals (L, N, PE if applicable)
- Ensure all connections are soldered + crimped
### Phase 4: LED Assembly
13. **Install LED bezel into back wall**
- 4× 5 mm LEDs press-fit into bezel holes (bodies recessed ~2 mm)
- Solder resistors (1 kΩ 1/4W) to LED anodes on rear
- Connect all LED cathodes to common GND line (black wire to PSU COM)
- Wire LED control lines to Jetson Orin NX GPIO (via I2C expander if needed)
14. **Connect LED header**
- 2.54 mm pin header (1×6) plugs into LED control harness
- Pin 1: LED1 (red, SEARCHING)
- Pin 2: LED2 (yellow, ALIGNED)
- Pin 3: LED3 (blue, CHARGING)
- Pin 4: LED4 (green, FULL)
- Pins 56: GND, +24V (power for LED feedback monitoring)
### Phase 5: Mechanical Assembly
15. **Bolt back wall to base**
- 3× M4×16 SHCS from underside of base
- Tighten to ~5 Nm (snug, don't overtighten plastic)
- Back wall should be perpendicular to base (verify with level)
16. **Attach V-guide rails**
- Left rail: 4× M4 fasteners into base inserts (front & rear attach)
- Right rail: Mirror (flip STL in slicer) or manually mirror geometry
- Verify V-channels are parallel & symmetrical (±2 mm tolerance)
17. **Mount ArUco marker frame**
- Bolt 4× M4×10 fasteners to frame feet (attach to base front)
- Insert laminated 100×100 mm ArUco marker (ID 42) into frame slot
- Verify marker is flat & centered (no curl or shadow)
18. **Attach rubber feet** (or floor anchors)
- 4× self-adhesive rubber feet on base underside corners
- OR drill M8 holes through base (optional: permanent floor mounting)
### Phase 6: Robot Receiver Assembly
19. **Assemble robot receiver** (per variant)
- **SaltyLab:** 2-piece stem collar (M4×16 clamps Ø25 mm stem)
- **SaltyRover:** Single flange piece (4× M4 to deck underbelly)
- **SaltyTank:** Single piece w/ extended nose (4× M4 to skid plate)
20. **Press brass pads into receiver**
- Ø12 mm pads press into 0.1 mm interference bores
- Apply Loctite 603 retaining compound to bore before pressing
- Manual arbor press @ ~1-2 tons force; pads should be proud 0.2 mm
21. **Solder receiver wires**
- 12 AWG wires (red/black) solder to M3 solder lugs on pad rear
- Route wires through wire channel on mount face
- Terminate to robot BMS/charging PCB input
---
## Wiring Diagram (24V System)
```
┌─────────────────────────────────────────────────────────────┐
│ MAINS INPUT (AC) │
│ 110/220 V AC │
└────────────┬────────────────────────────────────────────────┘
┌──────────────┐
│ IRM-240-24 │ 24V / 10A out (240W)
│ PSU │ ±5% regulated, open-frame
└──┬───────┬───┘
+24V │ │ GND
│ │
┌────┴┐ ┌─┴────┐
│ [F] │ │ [F] │ Fuse holder (25A slow-blow)
│ │ │ │
│ +24 │ │ GND │ 12 AWG silicone wire to back wall
│ │ │ │
└────┬┘ └─┬────┘
│ │
+24V│ │GND
▼ ▼
┌─────────────────┐
│ Back wall │
│ ┌───────────┐ │
│ │ POGO+ │ │ Spring-loaded contact pin (+24V)
│ │ POGO- │ │ Spring-loaded contact pin (GND)
│ └────┬──────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ LED 1-4 │ │ Red, Yellow, Blue, Green indicators
│ │ Resistors│ │ 1 kΩ limiting resistors (×4)
│ │ [GPIO] │ │ Control from Jetson Orin NX I2C
│ └───────────┘ │
└─────┬───────────┘
═════╧════════ DOCK / ROBOT AIR GAP (≤50 mm) ═════════════
┌──────────────────┐
│ Robot Receiver │
│ ┌────────────┐ │
│ │ Contact + │ │ Brass pad (Ø12×2 mm) [+24V]
│ │ Contact - │ │ Brass pad (Ø12×2 mm) [GND]
│ └──┬──┬──────┘ │
│ │ │ │
│ 12 AWG wires │ Red/black to BMS
│ │ │ │
│ ┌──▼──▼──┐ │
│ │ Robot │ │
│ │ BMS │ │
│ │Battery │ │ Charging current: 015A (typical)
│ └────────┘ │
└──────────────────┘
OPTIONAL — CURRENT SENSING (Diagnostic)
│ +24V
┌────┴────┐
│[INA219] │ I2C current monitor (0.1Ω sense resistor)
│ I2C 0x40│ Jetson reads dock current → state machine
└────┬────┘
│ GND
LED STATE MACHINE CONTROL (from docking_node.py):
State GPIO/Signal LED Output
─────────────────────────────────────────
SEARCHING GPIO H Red LED ON (20 mA, 1 kΩ)
ALIGNED GPIO H Yellow LED ON (pre-charge active)
CHARGING GPIO H Blue LED ON (>1 A charging)
FULL/COMPLETE GPIO H Green LED ON (float mode)
GPIO driven via Jetson Orin NX I2C LED driver (e.g., PCA9685)
or direct GPIO if firmware implements bitbang logic.
```
---
## Integration with ROS2 Docking Node (#489)
**Docking node location:** `./jetson/ros2_ws/src/saltybot_docking/docking_node.py`
### MQTT Topics
**Status reporting (outbound):**
```
saltybot/docking/status → { state, robot_id, contact_voltage, charge_current }
saltybot/docking/led → { red, yellow, blue, green } [0=OFF, 1=ON, blink_hz]
```
**Command subscriptions (inbound):**
```
saltybot/docking/reset → trigger dock reset (clear fault)
saltybot/docking/park → move robot out of dock (e.g., after full charge)
```
### Firmware Integration
**State machine (4 states):**
1. **SEARCHING** — No robot contact; dock waits for approach (ArUco marker detection via Jetson camera)
2. **ALIGNED** — Contact made (BMS pre-charge active); dock supplies trickle current (~100 mA) while robot capacitors charge
3. **CHARGING** — Main charge active; dock measures current via INA219, feedback to BMS
4. **FULL** — Target voltage reached (≥23.5 V, <100 mA draw); dock holds float voltage
**Current sensing feedback:**
- INA219 I2C shunt on 24V rail monitors dock-to-robot current
- Jetson polls at 10 Hz; state transitions trigger LED updates & MQTT publish
- Hysteresis prevents flickering (state valid for ≥2 sec)
---
## Testing Checklist
- [ ] **Electrical safety**
- [ ] 24V output isolated from mains AC (< 2.5 kV isolation @ 60 Hz)
- [ ] Fuse 25A blocks short-circuit (verify blow @ >30 A)
- [ ] Varistor clamps transient overvoltage (check 28V limit)
- [ ] All crimps are soldered + crimped (pull test: no slippage @ 10 lbf)
- [ ] **Mechanical**
- [ ] Base level on 4 rubber feet (no rocking)
- [ ] V-rails parallel within ±2 mm across 250 mm length
- [ ] Back wall perpendicular to base (level ±1°)
- [ ] Pogo pins extend 4 mm from back wall face (spring preload correct)
- [ ] **Contact alignment**
- [ ] Robot receiver pads contact pogo pins with ≥3 mm contact face overlap
- [ ] Contact resistance < 50 (measure with multimeter on lowest ohm scale during light press)
- [ ] No visible arcing or pitting (inspect pads after 10 charge cycles)
- [ ] **Power delivery**
- [ ] 24V output at PSU: 23.524.5 V (under load)
- [ ] 24V at pogo pins: ≥23.5 V (< 0.5 V droop @ 10 A)
- [ ] Robot receives 24V ± 1 V (measure at BMS input)
- [ ] **LED status**
- [ ] Red (SEARCHING) steady on before robot approach
- [ ] Yellow (ALIGNED) turns on when pads make contact
- [ ] Blue (CHARGING) turns on when charge current > 500 mA
- [ ] Green (FULL) turns on when current drops < 100 mA (float mode)
- [ ] **ArUco marker**
- [ ] Marker ID 42 is readable by Jetson camera from 1.5 m @ 90° angle
- [ ] No glare or shadow on marker (add diffuse lighting if needed)
- [ ] Marker detected by cv2.aruco in < 100 ms
- [ ] **MQTT integration**
- [ ] Dock publishes status every 5 sec (or on state change)
- [ ] LED state matches reported dock state
- [ ] Current sensing (INA219) reads within ±2% of true dock current
---
## Firmware/Software Requirements
### Jetson Orin NX (Docking controller)
**Python dependencies:**
```bash
pip install opencv-contrib-python # ArUco marker detection
pip install adafruit-circuitpython-ina219 # Current sensing
pip install rclpy # ROS2
pip install paho-mqtt # MQTT status reporting
```
**Key Python modules:**
- `cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)` → ArUco ID 42 detection
- `Adafruit_INA219` → I2C current monitoring @ 0x40
- GPIO library → LED control (via I2C LED driver or direct GPIO)
**ROS2 node:** `saltybot_docking/docking_node.py` (already present, Issue #489)
- Subscribes to `/docking/approach_request`
- Publishes to `/docking/status`, `/docking/led_state`
- MQTT gateway for legacy systems
---
## Files to Commit
**New files for Issue #505:**
```
chassis/
├── charging_dock_505.scad [Main dock 24V design]
├── charging_dock_receiver_505.scad [Robot receiver 24V variant]
├── ISSUE_505_CHARGING_DOCK_24V_DESIGN.md [This file]
├── charging_dock_505_BOM.csv [Excel-friendly BOM export]
└── charging_dock_505_WIRING_DIAGRAM.md [Detailed wiring guide]
docs/
└── Issue_505_Assembly_Guide.md [Step-by-step assembly photos + text]
```
---
## Revision History
| Date | Version | Changes |
|------|---------|---------|
| 2026-03-06 | 1.0 | Initial design (24V upgrade from Issue #159) |
---
## Next Steps
1. ✅ Design specification (this document)
2. ⏳ OpenSCAD CAD files (`charging_dock_505.scad`, `charging_dock_receiver_505.scad`)
3. ⏳ BOM export (CSV format for procurement)
4. ⏳ 3D-printed prototype testing
5. ⏳ Electrical integration with Jetson docking node
6. ⏳ ArUco marker calibration & documentation
7. ⏳ PR submission & merge to `main`
---
**Designer:** sl-mechanical
**Date:** 2026-03-06
**Status:** Design Specification Complete — Awaiting CAD Implementation

View File

@ -0,0 +1,531 @@
// ============================================================
// charging_dock_505.scad 24V Charging Dock Station
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
// ============================================================
//
// 24V upgraded dock (forked from Issue #159 5V design).
// Robot drives forward into V-guide funnel; spring-loaded pogo pins
// make contact with the robot receiver plate (charging_dock_receiver.scad).
//
// Power: 24 V / 10 A (240 W) via 2× high-current pogo pins (+/-)
// Alignment tolerance: ±20 mm lateral (V-guide funnels to centre)
//
// Dock architecture (top view):
//
// back wall (robot stops here)
// PSU shelf
// [PSU] [LED ×4]
// [POGO+][POGO-] pogo face (robot contact)
// \ /
// \ V-guide rails /
// \ /
// dock entry, ±20 mm funnel
//
// Components (this file):
// Part A dock_base() weighted base plate with ballast pockets
// Part B back_wall() upright back panel + pogo housing + LED bezel
// Part C guide_rail(side) V-funnel guide rail, L/R (print 2×)
// Part D aruco_mount() ArUco marker frame at dock entrance
// Part E psu_bracket() PSU retention bracket (rear of base)
// Part F led_bezel() 4-LED status bezel
//
// Robot-side receiver see charging_dock_receiver.scad
//
// Coordinate system:
// Z = 0 at dock floor (base plate top face)
// Y = 0 at back wall front face (robot approaches from +Y)
// X = 0 at dock centre
// Robot drives in -Y direction to dock.
//
// RENDER options:
// "assembly" full dock preview (default)
// "base_stl" base plate (print 1×)
// "back_wall_stl" back wall + pogo housing (print 1×)
// "guide_rail_stl" V-guide rail (print 2×, mirror for R side)
// "aruco_mount_stl" ArUco marker frame (print 1×)
// "psu_bracket_stl" PSU mounting bracket (print 1×)
// "led_bezel_stl" LED status bezel (print 1×)
//
// Export commands (Issue #505 24V variant):
// openscad charging_dock_505.scad -D 'RENDER="base_stl"' -o dock_505_base.stl
// openscad charging_dock_505.scad -D 'RENDER="back_wall_stl"' -o dock_505_back_wall.stl
// openscad charging_dock_505.scad -D 'RENDER="guide_rail_stl"' -o dock_505_guide_rail.stl
// openscad charging_dock_505.scad -D 'RENDER="aruco_mount_stl"' -o dock_505_aruco_mount.stl
// openscad charging_dock_505.scad -D 'RENDER="psu_bracket_stl"' -o dock_505_psu_bracket.stl
// openscad charging_dock_505.scad -D 'RENDER="led_bezel_stl"' -o dock_505_led_bezel.stl
// ============================================================
$fn = 64;
e = 0.01;
// Base plate dimensions
// NOTE: Enlarged for 24V PSU (IRM-240-24: 210×108×56 mm vs. IRM-30-5: 63×45×28 mm)
BASE_W = 340.0; // base width (X) increased for larger PSU bracket
BASE_D = 320.0; // base depth (Y, extends behind and in front of back wall)
BASE_T = 12.0; // base thickness
BASE_R = 10.0; // corner radius
// Ballast pockets (for steel hex bar / bolt weights):
// 4× pockets in base underside, accept M20 hex nuts (30 mm AF) stacked
BALLAST_N = 4;
BALLAST_W = 32.0; // pocket width (hex nut AF + 2 mm)
BALLAST_D = 32.0; // pocket depth
BALLAST_T = 8.0; // pocket depth ( BASE_T/2)
BALLAST_INSET_X = 50.0;
BALLAST_INSET_Y = 40.0;
// Floor bolt holes (M8, for bolting dock to bench/floor optional)
FLOOR_BOLT_D = 8.5;
FLOOR_BOLT_INSET_X = 30.0;
FLOOR_BOLT_INSET_Y = 25.0;
// Back wall (upright panel)
WALL_W = 250.0; // wall width (X) same as guide entry span
WALL_H = 85.0; // wall height (Z)
WALL_T = 10.0; // wall thickness (Y)
// Back wall Y position relative to base rear edge
// Wall sits at Y=0 (its front face); base extends behind it (-Y) and in front (+Y)
BASE_REAR_Y = -80.0; // base rear edge Y coordinate
// Pogo pin housing (in back wall front face)
// High-current pogo pins: Ø5.5 mm body, 20 mm long (compressed), 4 mm spring travel
// Rated 5 A each; 2× pins for +/- power
POGO_D = 5.5; // pogo pin body OD
POGO_BORE_D = 5.7; // bore diameter (0.2 mm clearance)
POGO_L = 20.0; // pogo full length (uncompressed)
POGO_TRAVEL = 4.0; // spring travel
POGO_FLANGE_D = 8.0; // pogo flange / retention shoulder OD
POGO_FLANGE_T = 1.5; // flange thickness
POGO_SPACING = 20.0; // CL-to-CL spacing between + and - pins
POGO_Z = 35.0; // pogo CL height above dock floor
POGO_PROTRUDE = 8.0; // pogo tip protrusion beyond wall face (uncompressed)
// Wiring channel behind pogo (runs down to base)
WIRE_CH_W = 8.0;
WIRE_CH_H = POGO_Z + 5;
// LED bezel (4 status LEDs in back wall, above pogo pins)
// LED order (left to right): Searching | Aligned | Charging | Full
// Colours (suggested): Red | Yellow | Blue | Green
LED_D = 5.0; // 5 mm through-hole LED
LED_BORE_D = 5.2; // bore diameter
LED_BEZEL_W = 80.0; // bezel plate width
LED_BEZEL_H = 18.0; // bezel plate height
LED_BEZEL_T = 4.0; // bezel plate thickness
LED_SPACING = 16.0; // LED centre-to-centre
LED_Z = 65.0; // LED centre height above floor
LED_INSET_D = 2.0; // LED recess depth (LED body recessed for protection)
// V-guide rails
// Robot receiver width (contact block): 30 mm.
// Alignment tolerance: ±20 mm entry gap = 30 + 2×20 = 70 mm.
// Guide rail tapers from 70 mm entry (at Y = GUIDE_L) to 30 mm exit (at Y=0).
// Each rail is a wedge-shaped wall.
GUIDE_L = 100.0; // guide rail length (Y depth, from back wall)
GUIDE_H = 50.0; // guide rail height (Z)
GUIDE_T = 8.0; // guide rail wall thickness
RECV_W = 30.0; // robot receiver contact block width
ENTRY_GAP = 70.0; // guide entry gap (= RECV_W + 2×20 mm tolerance)
EXIT_GAP = RECV_W + 2.0; // guide exit gap (2 mm clearance on each side)
// Derived: half-gap at entry = 35 mm, at exit = 16 mm; taper = 19 mm over 100 mm
// Half-angle = atan(19/100) 10.8° gentle enough for reliable self-alignment
// ArUco marker mount
// Mounted at dock entry arch (forward of guide rails), tilted 15° back.
// Robot camera acquires marker for coarse approach alignment.
// ArUco marker ID 42 (DICT_4X4_250), 100×100 mm (printed/laminated on paper).
ARUCO_MARKER_W = 100.0;
ARUCO_MARKER_H = 100.0;
ARUCO_FRAME_T = 3.0; // frame plate thickness
ARUCO_FRAME_BDR = 10.0; // frame border around marker
ARUCO_SLOT_T = 1.5; // marker slip-in slot depth
ARUCO_MAST_H = 95.0; // mast height above base (centres marker at camera height)
ARUCO_MAST_W = 10.0;
ARUCO_TILT = 15.0; // backward tilt (degrees) faces approaching robot
ARUCO_Y = GUIDE_L + 60; // mast Y position (in front of guide entry)
// PSU bracket
// Mean Well IRM-240-24 (24V 10A 240W): 210×108×56 mm body Issue #505 upgrade
// Bracket sits behind back wall, on base plate.
PSU_W = 220.0; // bracket internal width (+5 mm clearance per side for 210 mm PSU)
PSU_D = 118.0; // bracket internal depth (+5 mm clearance per side for 108 mm PSU)
PSU_H = 66.0; // bracket internal height (+5 mm top clearance for 56 mm PSU + ventilation)
PSU_T = 4.0; // bracket wall thickness (thicker for larger PSU mass)
PSU_Y = BASE_REAR_Y + PSU_D/2 + PSU_T + 10; // PSU Y centre
// Fasteners
M3_D = 3.3;
M4_D = 4.3;
M5_D = 5.3;
M8_D = 8.5;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly();
else if (RENDER == "base_stl") dock_base();
else if (RENDER == "back_wall_stl") back_wall();
else if (RENDER == "guide_rail_stl") guide_rail("left");
else if (RENDER == "aruco_mount_stl") aruco_mount();
else if (RENDER == "psu_bracket_stl") psu_bracket();
else if (RENDER == "led_bezel_stl") led_bezel();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
// Base plate
color("SaddleBrown", 0.85) dock_base();
// Back wall
color("Sienna", 0.85)
translate([0, 0, BASE_T])
back_wall();
// Left guide rail
color("Peru", 0.85)
translate([0, 0, BASE_T])
guide_rail("left");
// Right guide rail (mirror in X)
color("Peru", 0.85)
translate([0, 0, BASE_T])
mirror([1, 0, 0])
guide_rail("left");
// ArUco mount
color("DimGray", 0.85)
translate([0, 0, BASE_T])
aruco_mount();
// PSU bracket
color("DarkSlateGray", 0.80)
translate([0, PSU_Y, BASE_T])
psu_bracket();
// LED bezel
color("LightGray", 0.90)
translate([0, -WALL_T/2, BASE_T + LED_Z])
led_bezel();
// Ghost robot receiver approaching from +Y
%color("SteelBlue", 0.25)
translate([0, GUIDE_L + 30, BASE_T + POGO_Z])
cube([RECV_W, 20, 8], center = true);
// Ghost pogo pins
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
%color("Gold", 0.60)
translate([px, -POGO_PROTRUDE, BASE_T + POGO_Z])
rotate([90, 0, 0])
cylinder(d = POGO_D, h = POGO_L);
}
// ============================================================
// PART A DOCK BASE PLATE
// ============================================================
module dock_base() {
difference() {
// Main base block (rounded rect)
linear_extrude(BASE_T)
minkowski() {
square([BASE_W - 2*BASE_R,
BASE_D - 2*BASE_R], center = true);
circle(r = BASE_R);
}
// Ballast pockets (underside)
// 4× pockets: 2 front, 2 rear
for (bx = [-1, 1])
for (by = [-1, 1])
translate([bx * (BASE_W/2 - BALLAST_INSET_X),
by * (BASE_D/2 - BALLAST_INSET_Y),
-e])
cube([BALLAST_W, BALLAST_D, BALLAST_T + e], center = true);
// Floor bolt holes (M8, 4 corners)
for (bx = [-1, 1])
for (by = [-1, 1])
translate([bx * (BASE_W/2 - FLOOR_BOLT_INSET_X),
by * (BASE_D/2 - FLOOR_BOLT_INSET_Y), -e])
cylinder(d = FLOOR_BOLT_D, h = BASE_T + 2*e);
// Back wall attachment slots (M4, top face)
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
translate([bx, -BASE_D/4, BASE_T - 3])
cylinder(d = M4_D, h = 4 + e);
// Guide rail attachment holes (M4)
for (side = [-1, 1])
for (gy = [20, GUIDE_L - 20])
translate([side * (EXIT_GAP/2 + GUIDE_T/2), gy, BASE_T - 3])
cylinder(d = M4_D, h = 4 + e);
// Cable routing slot (from pogo wires to PSU, through base)
translate([0, -WALL_T - 5, -e])
cube([WIRE_CH_W, 15, BASE_T + 2*e], center = true);
// Anti-skid texture (front face chamfer)
// Chamfer front-bottom edge for easy robot approach
translate([0, BASE_D/2 + e, -e])
rotate([45, 0, 0])
cube([BASE_W + 2*e, 5, 5], center = true);
}
}
// ============================================================
// PART B BACK WALL (upright panel)
// ============================================================
module back_wall() {
difference() {
union() {
// Wall slab
translate([-WALL_W/2, -WALL_T, 0])
cube([WALL_W, WALL_T, WALL_H]);
// Pogo pin housing bosses (front face)
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, -WALL_T, POGO_Z])
rotate([90, 0, 0])
cylinder(d = POGO_FLANGE_D + 6,
h = POGO_PROTRUDE);
// Wiring channel reinforcement (inside wall face)
translate([-WIRE_CH_W/2 - 2, -WALL_T, 0])
cube([WIRE_CH_W + 4, 4, WIRE_CH_H]);
}
// Pogo pin bores (through wall into housing boss)
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, POGO_PROTRUDE + e, POGO_Z])
rotate([90, 0, 0]) {
// Main bore (full depth through wall + boss)
cylinder(d = POGO_BORE_D,
h = WALL_T + POGO_PROTRUDE + 2*e);
// Flange shoulder counterbore (retains pogo from pulling out)
translate([0, 0, WALL_T + POGO_PROTRUDE - POGO_FLANGE_T - 1])
cylinder(d = POGO_FLANGE_D + 0.4,
h = POGO_FLANGE_T + 2);
}
// Wiring channel (vertical slot, inside face base cable hole)
translate([-WIRE_CH_W/2, 0 + e, 0])
cube([WIRE_CH_W, WALL_T/2, WIRE_CH_H]);
// LED bezel recess (in front face, above pogo)
translate([-LED_BEZEL_W/2, -LED_BEZEL_T, LED_Z - LED_BEZEL_H/2])
cube([LED_BEZEL_W, LED_BEZEL_T + e, LED_BEZEL_H]);
// M4 base attachment bores (3 through bottom of wall)
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
translate([bx, -WALL_T/2, -e])
cylinder(d = M4_D, h = 8 + e);
// Cable tie slots (in wall body, for neat wire routing)
for (cz = [15, POGO_Z - 15])
translate([WIRE_CH_W/2 + 3, -WALL_T/2, cz])
cube([4, WALL_T + 2*e, 3], center = true);
// Lightening cutout (rear face pocket)
translate([-WALL_W/2 + 40, 0, 20])
cube([WALL_W - 80, WALL_T/2 + e, WALL_H - 30]);
}
}
// ============================================================
// PART C V-GUIDE RAIL
// ============================================================
// Print 2×; mirror in X for right side.
// Rail tapers from ENTRY_GAP/2 (at Y=GUIDE_L) to EXIT_GAP/2 (at Y=0).
// Inner (guiding) face is angled; outer face is vertical.
module guide_rail(side = "left") {
// Inner face X at back wall = EXIT_GAP/2
// Inner face X at entry = ENTRY_GAP/2
x_back = EXIT_GAP/2; // 16 mm
x_entry = ENTRY_GAP/2; // 35 mm
difference() {
union() {
// Main wedge body
// Hull between two rectangles: narrow at Y=0, wide at Y=GUIDE_L
hull() {
// Back end (at Y=0, flush with back wall)
translate([x_back, 0, 0])
cube([GUIDE_T, e, GUIDE_H]);
// Entry end (at Y=GUIDE_L)
translate([x_entry, GUIDE_L, 0])
cube([GUIDE_T, e, GUIDE_H]);
}
// Entry flare (chamfered lip at guide entry for bump-entry)
hull() {
translate([x_entry, GUIDE_L, 0])
cube([GUIDE_T, e, GUIDE_H]);
translate([x_entry + 15, GUIDE_L + 20, 0])
cube([GUIDE_T, e, GUIDE_H * 0.6]);
}
}
// M4 base attachment bores
for (gy = [20, GUIDE_L - 20])
translate([x_back + GUIDE_T/2, gy, -e])
cylinder(d = M4_D, h = 8 + e);
// Chamfer on inner top corner (smooth robot entry)
translate([x_back - e, -e, GUIDE_H - 5])
rotate([0, -45, 0])
cube([8, GUIDE_L + 30, 8]);
}
}
// ============================================================
// PART D ArUco MARKER MOUNT
// ============================================================
// Free-standing mast at dock entry. Mounts to base plate.
// Marker face tilted 15° toward approaching robot.
// Accepts 100×100 mm printed/laminated paper marker in slot.
module aruco_mount() {
frame_w = ARUCO_MARKER_W + 2*ARUCO_FRAME_BDR;
frame_h = ARUCO_MARKER_H + 2*ARUCO_FRAME_BDR;
mast_y = ARUCO_Y;
union() {
// Mast column
translate([-ARUCO_MAST_W/2, mast_y - ARUCO_MAST_W/2, 0])
cube([ARUCO_MAST_W, ARUCO_MAST_W, ARUCO_MAST_H]);
// Marker frame (tilted back ARUCO_TILT°)
translate([0, mast_y, ARUCO_MAST_H])
rotate([-ARUCO_TILT, 0, 0]) {
difference() {
// Frame plate
translate([-frame_w/2, -ARUCO_FRAME_T, -frame_h/2])
cube([frame_w, ARUCO_FRAME_T, frame_h]);
// Marker window (cutout for marker visibility)
translate([-ARUCO_MARKER_W/2, -ARUCO_FRAME_T - e,
-ARUCO_MARKER_H/2])
cube([ARUCO_MARKER_W,
ARUCO_FRAME_T + 2*e,
ARUCO_MARKER_H]);
// Marker slip-in slot (insert from side)
translate([-frame_w/2 - e,
-ARUCO_SLOT_T - 0.3,
-ARUCO_MARKER_H/2])
cube([frame_w + 2*e,
ARUCO_SLOT_T + 0.3,
ARUCO_MARKER_H]);
}
}
// Mast base foot (M4 bolts to dock base)
difference() {
translate([-20, mast_y - 20, 0])
cube([40, 40, 5]);
for (fx = [-12, 12]) for (fy = [-12, 12])
translate([fx, mast_y + fy, -e])
cylinder(d = M4_D, h = 6 + e);
}
}
}
// ============================================================
// PART E PSU BRACKET
// ============================================================
// Open-top retention bracket for PSU module.
// PSU slides in from top; 2× M3 straps or cable ties retain it.
// Bracket bolts to base plate via 4× M4 screws.
module psu_bracket() {
difference() {
union() {
// Outer bracket box (open top)
_box_open_top(PSU_W + 2*PSU_T,
PSU_D + 2*PSU_T,
PSU_H + PSU_T);
// Base flange
translate([-(PSU_W/2 + PSU_T + 8),
-(PSU_D/2 + PSU_T + 8), -PSU_T])
cube([PSU_W + 2*PSU_T + 16,
PSU_D + 2*PSU_T + 16, PSU_T]);
}
// PSU cavity
translate([0, 0, PSU_T])
cube([PSU_W, PSU_D, PSU_H + e], center = true);
// Ventilation slots (sides)
for (a = [0, 90, 180, 270])
rotate([0, 0, a])
translate([0, (PSU_D/2 + PSU_T)/2, PSU_H/2 + PSU_T])
for (sz = [-PSU_H/4, 0, PSU_H/4])
translate([0, 0, sz])
cube([PSU_W * 0.5, PSU_T + 2*e, 5],
center = true);
// Cable exit slot (bottom)
translate([0, 0, -e])
cube([15, PSU_D + 2*PSU_T + 2*e, PSU_T + 2*e],
center = true);
// Base flange M4 bolts
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx * (PSU_W/2 + PSU_T + 4),
fy * (PSU_D/2 + PSU_T + 4),
-PSU_T - e])
cylinder(d = M4_D, h = PSU_T + 2*e);
// Cable tie slots
for (sz = [PSU_H/3, 2*PSU_H/3])
translate([0, 0, PSU_T + sz])
cube([PSU_W + 2*PSU_T + 2*e, 4, 4], center = true);
}
}
module _box_open_top(w, d, h) {
difference() {
cube([w, d, h], center = true);
translate([0, 0, PSU_T + e])
cube([w - 2*PSU_T, d - 2*PSU_T, h], center = true);
}
}
// ============================================================
// PART F LED STATUS BEZEL
// ============================================================
// 4 × 5 mm LEDs in a row. Press-fits into recess in back wall.
// LED labels (LR): SEARCHING | ALIGNED | CHARGING | FULL
// Suggested colours: Red | Yellow | Blue | Green
module led_bezel() {
difference() {
// Bezel plate
cube([LED_BEZEL_W, LED_BEZEL_T, LED_BEZEL_H], center = true);
// 4× LED bores
for (i = [-1.5, -0.5, 0.5, 1.5])
translate([i * LED_SPACING, -LED_BEZEL_T - e, 0])
rotate([90, 0, 0]) {
// LED body bore (recess, not through)
cylinder(d = LED_BORE_D + 1,
h = LED_INSET_D + e);
// LED pin bore (through bezel)
translate([0, 0, LED_INSET_D])
cylinder(d = LED_BORE_D,
h = LED_BEZEL_T + 2*e);
}
// Label recesses between LEDs (for colour-dot stickers or printed inserts)
for (i = [-1.5, -0.5, 0.5, 1.5])
translate([i * LED_SPACING, LED_BEZEL_T/2, LED_BEZEL_H/2 - 3])
cube([LED_SPACING - 3, 1 + e, 5], center = true);
// M3 mounting holes (2× into back wall)
for (mx = [-LED_BEZEL_W/2 + 6, LED_BEZEL_W/2 - 6])
translate([mx, -LED_BEZEL_T - e, 0])
rotate([90, 0, 0])
cylinder(d = M3_D, h = LED_BEZEL_T + 2*e);
}
}

View File

@ -0,0 +1,41 @@
Item,Description,Specification,Quantity,Unit Cost,Total Cost,Source Notes
E1,Power Supply,Mean Well IRM-240-24 / Hi-Link HLK-240M24 (24V 10A 240W),1,$50.00,$50.00,Digi-Key / Amazon
E2,12 AWG Silicone Wire,Red + Black 600V rated 5m spool,1,$15.00,$15.00,McMaster-Carr / AliExpress
E3,PG7 Cable Gland,M20 IP67 5-8mm cable,2,$3.00,$6.00,AliExpress / Heilind
E4,Varistor (MOV),18-28V 1kA,1,$1.00,$1.00,Digi-Key
E5,Fuse 25A,T25 Slow-blow 5x20mm,1,$0.50,$0.50,Digi-Key
E6,Fuse Holder,5x20mm inline 20A rated,1,$2.00,$2.00,Amazon
E7,Crimp Ring Terminals,M3 12 AWG tin-plated,8,$0.20,$1.60,Heilind / AliExpress
E8,Strain Relief Sleeve,5mm ID silicone 1m,1,$5.00,$5.00,McMaster-Carr
C1,Pogo Pin Assembly,"Spring-loaded Ø5.5mm 20mm 20A 4mm travel",2,$10.00,$20.00,Preci-Dip / Jst / AliExpress
C2,Brass Contact Pad,Ø12x2mm H68 brass bare,2,$3.00,$6.00,OnlineMetals / Metals USA
C3,Solder Lug M3,Copper ring tin-plated,4,$0.40,$1.60,Heilind / Amazon
L1,5mm LED Red,2.0V 20mA diffuse,1,$0.30,$0.30,Digi-Key
L2,5mm LED Yellow,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
L3,5mm LED Blue,3.2V 20mA diffuse,1,$0.50,$0.50,Digi-Key
L4,5mm LED Green,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
R1-R4,Resistor 1kΩ 1/4W,Metal film 1% tolerance,4,$0.10,$0.40,Digi-Key
J1,Pin Header 2.54mm,1x6 right-angle,1,$0.50,$0.50,Digi-Key
S1,INA219 I2C Shunt Monitor,16-bit I2C 0x40 26V max (Optional),1,$5.00,$5.00,Adafruit / Digi-Key
S2,SMD Resistor 0.1Ω,1206 1W (Optional current sense),1,$1.00,$1.00,Digi-Key
M1,M20 Hex Nut,Steel DIN 934 ~86g,8,$0.80,$6.40,Grainger / Home Depot
M2,M4x16 SHCS,Stainless A4 DIN 912,16,$0.30,$4.80,Grainger
M3,M4x10 BHCS,Stainless A4 DIN 7380,8,$0.25,$2.00,Grainger
M4,M4 Heat-Set Insert,Brass threaded,20,$0.15,$3.00,McMaster-Carr
M5,M3x16 SHCS,Stainless,4,$0.20,$0.80,Grainger
M6,M3 Hex Nut,DIN 934,4,$0.10,$0.40,Grainger
M7,M8x40 BHCS,Zinc-plated floor anchor,4,$0.50,$2.00,Grainger
M8,Rubber Foot,Ø20x5mm self-adhesive,4,$0.80,$3.20,Amazon
A1,ArUco Marker Print,"100x100mm ID=42 DICT_4X4_250 glossy photo (qty 2)",2,$1.50,$3.00,Print locally / AliExpress
A2,Lamination Pouch,A4 80µm,2,$0.40,$0.80,Amazon / Staples
A3,Acrylic Cover Sheet,Clear 3mm 150x150mm,1,$3.00,$3.00,McMaster-Carr
X1,Solder Wire,63/37 Sn/Pb lead-free 1m,1,$3.00,$3.00,Digi-Key
X2,Flux Paste,No-clean 25mL,1,$4.00,$4.00,Digi-Key
X3,Loctite 243,Thread-locker 10mL,1,$4.00,$4.00,Grainger
X4,Epoxy Adhesive,Two-part 25mL,1,$6.00,$6.00,Home Depot
P1,PETG Filament (3D Print),"Natural/White 1kg ±15% waste factor",2.5,$20.00,$50.00,Prusament / Overture
,,,,,
SUBTOTAL (Electrical + Hardware + Consumables),,,,,,$234.00,excludes 3D printing
SUBTOTAL (With 3D filament @ $20/kg),,,,,,$284.00,all materials
LABOR ESTIMATE (Assembly ~4-6 hrs),,,,,,$150-225,tecnico time
TOTAL PROJECT COST (Material + Labor),,,,,,$434-509,per dock
Can't render this file because it has a wrong number of fields in line 37.

View File

@ -0,0 +1,332 @@
// ============================================================
// charging_dock_receiver_505.scad Robot-Side Charging Receiver (24V)
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
// ============================================================
//
// Robot-side contact plate that mates with the 24V charging dock pogo pins.
// Forked from Issue #159 receiver (contact geometry unchanged; 12 AWG wire bore).
// Each robot variant has a different mounting interface; the contact
// geometry is identical across all variants (same pogo pin spacing).
//
// Variants:
// A lab_receiver() SaltyLab mounts to underside of stem base ring
// B rover_receiver() SaltyRover mounts to chassis belly (M4 deck holes)
// C tank_receiver() SaltyTank mounts to skid plate / hull floor
//
// Contact geometry (common across variants):
// 2× brass contact pads, Ø12 mm × 2 mm (press-fit into PETG housing)
// Pad spacing: 20 mm CL-to-CL (matches dock POGO_SPACING exactly)
// Contact face Z height matches dock pogo pin Z when robot is level
// Polarity: marked + on top pin (conventional: positive = right when
// facing dock; negative = left) must match dock wiring.
//
// Approach guide nose:
// A chamfered V-nose on the forward face guides the receiver block
// into the dock's V-funnel. Taper half-angle 14° matches guide rails.
// Nose width = RECV_W = 30 mm (matches dock EXIT_GAP - 2 mm clearance).
//
// Coordinate convention:
// Z = 0 at receiver mounting face (against robot chassis/deck underside).
// +Z points downward (toward dock floor).
// Contact pads face +Y (toward dock back wall when docked).
// Receiver centred on X = 0 (robot centreline).
//
// RENDER options:
// "assembly" all 3 receivers side by side
// "lab_stl" SaltyLab receiver (print 1×)
// "rover_stl" SaltyRover receiver (print 1×)
// "tank_stl" SaltyTank receiver (print 1×)
// "contact_pad_2d" DXF Ø12 mm brass pad profile (order from metal shop)
//
// Export (Issue #505 24V variant):
// openscad charging_dock_receiver_505.scad -D 'RENDER="lab_stl"' -o receiver_505_lab.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="rover_stl"' -o receiver_505_rover.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="tank_stl"' -o receiver_505_tank.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="contact_pad_2d"' -o contact_pad_505.dxf
// ============================================================
$fn = 64;
e = 0.01;
// Contact geometry (must match charging_dock.scad)
POGO_SPACING = 20.0; // CL-to-CL (dock POGO_SPACING)
PAD_D = 12.0; // contact pad OD (brass disc)
PAD_T = 2.0; // contact pad thickness
PAD_RECESS = 1.8; // pad pressed into housing (0.2 mm proud for contact)
PAD_PROUD = 0.2; // pad face protrudes from housing face
// Common receiver body geometry
RECV_W = 30.0; // receiver body width (X) matches dock EXIT_GAP inner
RECV_D = 25.0; // receiver body depth (Y, docking direction)
RECV_H = 12.0; // receiver body height (Z, from mount face down)
RECV_R = 3.0; // corner radius
// V-nose geometry (front Y face faces dock back wall)
NOSE_CHAMFER = 10.0; // chamfer depth on X corners of front face
// Polarity indicator slot (on top/mount face: + on right, - on left)
POL_SLOT_W = 4.0;
POL_SLOT_D = 8.0;
POL_SLOT_H = 1.0;
// Fasteners
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;
// Mounting patterns
// SaltyLab stem base ring (Ø25 mm stem, 4× M3 in ring at Ø40 mm BC)
LAB_BC_D = 40.0;
LAB_BOLT_D = M3_D;
LAB_COLLAR_H = 15.0; // collar height above receiver body
// SaltyRover deck (M4 grid pattern, 30.5×30.5 mm matching FC pattern on deck)
// Receiver uses 4× M4 holes at ±20 mm from centre (clear of deck electronics)
ROVER_BOLT_SPC = 40.0;
// SaltyTank skid plate (M4 holes matching skid plate bolt pattern)
// Uses 4× M4 at ±20 mm X, ±10 mm Y (inset from skid plate M4 positions)
TANK_BOLT_SPC_X = 40.0;
TANK_BOLT_SPC_Y = 20.0;
TANK_NOSE_L = 20.0; // extended nose for tank (wider hull)
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly();
else if (RENDER == "lab_stl") lab_receiver();
else if (RENDER == "rover_stl") rover_receiver();
else if (RENDER == "tank_stl") tank_receiver();
else if (RENDER == "contact_pad_2d") {
projection(cut = true) translate([0, 0, -0.5])
linear_extrude(1) circle(d = PAD_D);
}
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
// SaltyLab receiver
color("RoyalBlue", 0.85)
translate([-80, 0, 0])
lab_receiver();
// SaltyRover receiver
color("OliveDrab", 0.85)
translate([0, 0, 0])
rover_receiver();
// SaltyTank receiver
color("SaddleBrown", 0.85)
translate([80, 0, 0])
tank_receiver();
}
// ============================================================
// COMMON RECEIVER BODY
// ============================================================
// Internal helper: the shared contact housing + V-nose.
// Orientation: mount face = +Z top; contact face = +Y front.
// All variant-specific modules call this, then add their mount interface.
module _receiver_body() {
difference() {
union() {
// Main housing block (rounded)
linear_extrude(RECV_H)
_recv_profile_2d();
// V-nose chamfer reinforcement ribs
// Two diagonal ribs at 45° reinforce the chamfered corners
for (sx = [-1, 1])
hull() {
translate([sx*(RECV_W/2 - NOSE_CHAMFER),
RECV_D/2, 0])
cylinder(d = 3, h = RECV_H * 0.6);
translate([sx*(RECV_W/2), RECV_D/2 - NOSE_CHAMFER, 0])
cylinder(d = 3, h = RECV_H * 0.6);
}
}
// Contact pad bores (2× Ø12 mm, press-fit)
// Pads face +Y; bores from Y face into housing
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, RECV_D/2 + e, RECV_H/2])
rotate([90, 0, 0]) {
// Pad press-fit bore
cylinder(d = PAD_D + 0.1,
h = PAD_RECESS + e);
// Wire bore (behind pad, to mount face)
translate([0, 0, PAD_RECESS])
cylinder(d = 3.0,
h = RECV_D + 2*e);
}
// Polarity indicator slots on top face
// "+" slot: right pad (+X side)
translate([POGO_SPACING/2, 0, -e])
cube([POL_SLOT_W, POL_SLOT_D, POL_SLOT_H + e], center = true);
// "-" indent: left pad (no slot = negative)
// Wire routing channel (on mount face / underside)
// Trough connecting both pad bores for neat wire run
translate([0, RECV_D/2 - POGO_SPACING/2, RECV_H - 3])
cube([POGO_SPACING + 6, POGO_SPACING, 4], center = true);
}
}
// 2D profile of receiver body with chamfered V-nose
module _recv_profile_2d() {
hull() {
// Rear corners (full width)
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - RECV_R), -RECV_D/2 + RECV_R])
circle(r = RECV_R);
// Front corners (chamfered narrowed by NOSE_CHAMFER)
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - NOSE_CHAMFER - RECV_R),
RECV_D/2 - RECV_R])
circle(r = RECV_R);
}
}
// ============================================================
// PART A SALTYLAB RECEIVER
// ============================================================
// Mounts to the underside of the SaltyLab chassis stem base ring.
// Split collar grips Ø25 mm stem; receiver body hangs below collar.
// Z height set so contact pads align with dock pogo pins when robot
// rests on flat surface (robot wheel-to-contact-pad height calibrated).
//
// Receiver height above floor: tune LAB_CONTACT_Z in firmware (UWB/ArUco
// approach). Mechanically: receiver sits ~35 mm above ground (stem base
// height), matching dock POGO_Z = 35 mm.
module lab_receiver() {
collar_od = 46.0; // matches sensor_rail.scad STEM_COL_OD
collar_h = LAB_COLLAR_H;
union() {
// Common receiver body
_receiver_body();
// Stem collar (split, 2 halves joined with M4 bolts)
// Only the front half printed here; rear half is mirror.
translate([0, -RECV_D/2, RECV_H])
difference() {
// Half-collar cylinder
rotate_extrude(angle = 180)
translate([collar_od/2 - 8, 0, 0])
square([8, collar_h]);
// Stem bore clearance
translate([0, 0, -e])
cylinder(d = 25.5, h = collar_h + 2*e);
// 2× M4 clamping bolt bores (through collar flanges)
for (cx = [-collar_od/2 + 4, collar_od/2 - 4])
translate([cx, 0, collar_h/2])
rotate([90, 0, 0])
cylinder(d = M4_D,
h = collar_od + 2*e,
center = true);
}
// M3 receiver-to-collar bolts
// 4× M3 holes connecting collar flange to receiver body top
// (These are mounting holes for assembly; not holes in the part)
}
}
// ============================================================
// PART B SALTYOVER RECEIVER
// ============================================================
// Mounts to the underside of the SaltyRover deck plate.
// 4× M4 bolts into deck underside (blind holes tapped in deck).
// Receiver sits flush with deck belly; contact pads protrude 5 mm below.
// Dock pogo Z = 35 mm must equal ground-to-deck-belly height for rover
// (approximately 60 mm chassis clearance shim with spacer if needed).
module rover_receiver() {
mount_h = 5.0; // mounting flange thickness
union() {
// Common receiver body
_receiver_body();
// Mounting flange (attaches to deck belly)
difference() {
translate([-(ROVER_BOLT_SPC/2 + 12),
-RECV_D/2 - 10,
RECV_H])
cube([ROVER_BOLT_SPC + 24,
RECV_D + 20,
mount_h]);
// 4× M4 bolt holes
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx*ROVER_BOLT_SPC/2,
fy*(RECV_D/2 + 5),
RECV_H - e])
cylinder(d = M4_D,
h = mount_h + 2*e);
// Weight-reduction pockets
for (sx = [-1, 1])
translate([sx*(ROVER_BOLT_SPC/4 + 6),
0, RECV_H + 1])
cube([ROVER_BOLT_SPC/2 - 4, RECV_D - 4, mount_h],
center = true);
}
}
}
// ============================================================
// PART C SALTYTANK RECEIVER
// ============================================================
// Mounts to SaltyTank hull floor or replaces a section of skid plate.
// Extended front nose (TANK_NOSE_L) for tank's wider hull approach.
// Contact pads exposed through skid plate via a 30×16 mm slot.
// Ground clearance: tank chassis = 90 mm; dock POGO_Z = 35 mm.
// Use ramp shim (see BOM) under dock base to elevate pogo pins to 90 mm
// OR set POGO_Z = 90 in dock for a tank-specific dock configuration.
// Cross-variant dock: set POGO_Z per robot if heights differ.
// Compromise: POGO_Z = 60 mm with 25 mm ramp for tank, 25 mm spacer for lab.
module tank_receiver() {
mount_h = 4.0;
nose_l = RECV_D/2 + TANK_NOSE_L;
union() {
// Common receiver body
_receiver_body();
// Extended nose for tank approach
// Additional chamfered wedge ahead of standard receiver body
hull() {
// Receiver front face corners
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - NOSE_CHAMFER), RECV_D/2, 0])
cylinder(d = 2*RECV_R, h = RECV_H * 0.5);
// Extended nose tip (narrowed to 20 mm)
for (sx = [-1, 1])
translate([sx*10, RECV_D/2 + TANK_NOSE_L, 0])
cylinder(d = 2*RECV_R, h = RECV_H * 0.4);
}
// Mounting flange (bolts to tank skid plate)
difference() {
translate([-(TANK_BOLT_SPC_X/2 + 10),
-RECV_D/2 - 8,
RECV_H])
cube([TANK_BOLT_SPC_X + 20,
RECV_D + 16,
mount_h]);
// 4× M4 bolt holes
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx*TANK_BOLT_SPC_X/2,
fy*TANK_BOLT_SPC_Y/2,
RECV_H - e])
cylinder(d = M4_D,
h = mount_h + 2*e);
}
}
}

View File

@ -0,0 +1,39 @@
# Audio Pipeline (Issue #503)
Comprehensive audio pipeline for Salty Bot with full voice interaction support.
## Features
- **Hardware**: Jabra SPEAK 810 USB audio device integration
- **Wake Word**: openwakeword "Hey Salty" detection
- **STT**: whisper.cpp running on Jetson GPU (small/base/medium/large models)
- **TTS**: Piper synthesis with voice switching
- **State Machine**: listening → processing → speaking
- **MQTT**: Real-time status reporting
- **Metrics**: Latency tracking and performance monitoring
## ROS2 Topics
Published:
- `/saltybot/speech/transcribed_text` (String): Final STT output
- `/saltybot/audio/state` (String): Current audio state
- `/saltybot/audio/status` (String): JSON metrics with latencies
## MQTT Topics
- `saltybot/audio/state`: Current state
- `saltybot/audio/status`: Complete status JSON
## Launch
```bash
ros2 launch saltybot_audio_pipeline audio_pipeline.launch.py
```
## Configuration
See `config/audio_pipeline_params.yaml` for tuning:
- `device_name`: Jabra device
- `wake_word_threshold`: 0.5 (0.0-1.0)
- `whisper_model`: small/base/medium/large
- `mqtt_enabled`: true/false

View File

@ -0,0 +1,18 @@
audio_pipeline_node:
ros__parameters:
device_name: "Jabra SPEAK 810"
audio_device_index: -1
sample_rate: 16000
chunk_size: 512
wake_word_model: "hey_salty"
wake_word_threshold: 0.5
wake_word_timeout_s: 8.0
whisper_model: "small"
whisper_compute_type: "float16"
whisper_language: ""
tts_voice_path: "/models/piper/en_US-lessac-medium.onnx"
tts_sample_rate: 22050
mqtt_enabled: true
mqtt_broker: "localhost"
mqtt_port: 1883
mqtt_base_topic: "saltybot/audio"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env python3
from launch import LaunchDescription
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
pkg_dir = get_package_share_directory("saltybot_audio_pipeline")
config_path = os.path.join(pkg_dir, "config", "audio_pipeline_params.yaml")
return LaunchDescription([
Node(
package="saltybot_audio_pipeline",
executable="audio_pipeline_node",
name="audio_pipeline_node",
parameters=[config_path],
output="screen",
emulate_tty=True,
),
])

View File

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<package format="3">
<name>saltybot_audio_pipeline</name>
<version>1.0.0</version>
<description>Full audio pipeline: Jabra SPEAK 810, wake word, STT, TTS with MQTT (Issue #503)</description>
<maintainer email="seb@saltylab.ai">Salty Lab</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>std_msgs</depend>
<test_depend>pytest</test_depend>
</package>

View File

@ -0,0 +1,2 @@
"""Audio pipeline for Salty Bot."""
__version__ = "1.0.0"

View File

@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""audio_pipeline_node.py — Full audio pipeline with Jabra SPEAK 810 I/O (Issue #503)."""
from __future__ import annotations
import json, os, threading, time
from enum import Enum
from dataclasses import dataclass, asdict
from typing import Optional
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile
from std_msgs.msg import String
from .audio_utils import EnergyVAD, UtteranceSegmenter, AudioBuffer, pcm16_to_float32, float32_to_pcm16, resample_audio
try:
import paho.mqtt.client as mqtt
_MQTT_AVAILABLE = True
except ImportError:
_MQTT_AVAILABLE = False
class AudioState(Enum):
IDLE = "idle"
LISTENING = "listening"
WAKE_WORD_DETECTED = "wake_detected"
PROCESSING = "processing"
SPEAKING = "speaking"
ERROR = "error"
@dataclass
class AudioMetrics:
wake_to_stt_ms: float = 0.0
stt_processing_ms: float = 0.0
tts_synthesis_ms: float = 0.0
total_latency_ms: float = 0.0
transcribed_text: str = ""
speaker_id: str = "unknown"
error_msg: str = ""
class MqttClient:
def __init__(self, broker: str, port: int, base_topic: str):
self.broker = broker
self.port = port
self.base_topic = base_topic
self._client = None
self._connected = False
if _MQTT_AVAILABLE:
try:
self._client = mqtt.Client(client_id=f"saltybot-audio-{int(time.time())}")
self._client.on_connect = lambda c, u, f, rc: setattr(self, '_connected', rc == 0)
self._client.on_disconnect = lambda c, u, rc: setattr(self, '_connected', False)
self._client.connect_async(broker, port, keepalive=60)
self._client.loop_start()
except Exception as e:
print(f"MQTT init failed: {e}")
def publish(self, topic: str, payload: str) -> bool:
if not self._client or not self._connected:
return False
try:
self._client.publish(topic, payload, qos=0)
return True
except Exception:
return False
def disconnect(self) -> None:
if self._client:
self._client.loop_stop()
self._client.disconnect()
class JabraAudioDevice:
def __init__(self, device_name: str = "Jabra SPEAK 810", device_idx: int = -1):
self.device_name = device_name
self.device_idx = device_idx
self._pa = None
self._input_stream = None
self._output_stream = None
self._is_open = False
def open(self, sample_rate: int = 16000, chunk_size: int = 512) -> bool:
try:
import pyaudio
self._pa = pyaudio.PyAudio()
if self.device_idx < 0:
self.device_idx = self._find_device_index() or None
self._input_stream = self._pa.open(format=pyaudio.paInt16, channels=1, rate=sample_rate,
input=True, input_device_index=self.device_idx, frames_per_buffer=chunk_size, start=False)
self._output_stream = self._pa.open(format=pyaudio.paInt16, channels=1, rate=sample_rate,
output=True, output_device_index=self.device_idx, frames_per_buffer=chunk_size, start=False)
self._is_open = True
return True
except Exception as e:
print(f"Failed to open Jabra device: {e}")
return False
def _find_device_index(self) -> int:
try:
import pyaudio
pa = pyaudio.PyAudio()
for i in range(pa.get_device_count()):
info = pa.get_device_info_by_index(i)
if "jabra" in info["name"].lower() or "speak" in info["name"].lower():
return i
except Exception:
pass
return -1
def read_chunk(self, chunk_size: int = 512) -> Optional[bytes]:
if not self._is_open or not self._input_stream:
return None
try:
return self._input_stream.read(chunk_size, exception_on_overflow=False)
except Exception:
return None
def write_chunk(self, pcm_data: bytes) -> bool:
if not self._is_open or not self._output_stream:
return False
try:
self._output_stream.write(pcm_data)
return True
except Exception:
return False
def close(self) -> None:
if self._input_stream:
self._input_stream.stop_stream()
self._input_stream.close()
if self._output_stream:
self._output_stream.stop_stream()
self._output_stream.close()
if self._pa:
self._pa.terminate()
self._is_open = False
class AudioPipelineNode(Node):
def __init__(self) -> None:
super().__init__("audio_pipeline_node")
for param, default in [
("device_name", "Jabra SPEAK 810"),
("audio_device_index", -1),
("sample_rate", 16000),
("chunk_size", 512),
("wake_word_model", "hey_salty"),
("wake_word_threshold", 0.5),
("wake_word_timeout_s", 8.0),
("whisper_model", "small"),
("whisper_compute_type", "float16"),
("whisper_language", ""),
("tts_voice_path", "/models/piper/en_US-lessac-medium.onnx"),
("tts_sample_rate", 22050),
("mqtt_enabled", True),
("mqtt_broker", "localhost"),
("mqtt_port", 1883),
("mqtt_base_topic", "saltybot/audio"),
]:
self.declare_parameter(param, default)
device_name = self.get_parameter("device_name").value
device_idx = self.get_parameter("audio_device_index").value
self._sample_rate = self.get_parameter("sample_rate").value
self._chunk_size = self.get_parameter("chunk_size").value
self._ww_model = self.get_parameter("wake_word_model").value
self._ww_thresh = self.get_parameter("wake_word_threshold").value
self._whisper_model = self.get_parameter("whisper_model").value
self._compute_type = self.get_parameter("whisper_compute_type").value
self._whisper_lang = self.get_parameter("whisper_language").value or None
self._tts_voice_path = self.get_parameter("tts_voice_path").value
self._tts_rate = self.get_parameter("tts_sample_rate").value
mqtt_enabled = self.get_parameter("mqtt_enabled").value
mqtt_broker = self.get_parameter("mqtt_broker").value
mqtt_port = self.get_parameter("mqtt_port").value
mqtt_topic = self.get_parameter("mqtt_base_topic").value
qos = QoSProfile(depth=10)
self._text_pub = self.create_publisher(String, "/saltybot/speech/transcribed_text", qos)
self._state_pub = self.create_publisher(String, "/saltybot/audio/state", qos)
self._status_pub = self.create_publisher(String, "/saltybot/audio/status", qos)
self._state = AudioState.IDLE
self._state_lock = threading.Lock()
self._metrics = AudioMetrics()
self._running = False
self._jabra = JabraAudioDevice(device_name, device_idx)
self._oww = None
self._whisper = None
self._tts_voice = None
self._mqtt = None
if mqtt_enabled and _MQTT_AVAILABLE:
try:
self._mqtt = MqttClient(mqtt_broker, mqtt_port, mqtt_topic)
self.get_logger().info(f"MQTT enabled: {mqtt_broker}:{mqtt_port}/{mqtt_topic}")
except Exception as e:
self.get_logger().warn(f"MQTT init failed: {e}")
self._vad = EnergyVAD(threshold_db=-35.0)
self._segmenter = UtteranceSegmenter(self._vad, sample_rate=self._sample_rate)
self._audio_buffer = AudioBuffer(capacity_s=30.0, sample_rate=self._sample_rate)
threading.Thread(target=self._init_pipeline, daemon=True).start()
def _init_pipeline(self) -> None:
self.get_logger().info("Initializing audio pipeline...")
t0 = time.time()
if not self._jabra.open(self._sample_rate, self._chunk_size):
self._set_state(AudioState.ERROR)
self._metrics.error_msg = "Failed to open Jabra device"
return
try:
from openwakeword.model import Model as OWWModel
self._oww = OWWModel(wakeword_models=[self._ww_model])
self.get_logger().info(f"openwakeword '{self._ww_model}' loaded")
except Exception as e:
self.get_logger().warn(f"openwakeword failed: {e}")
try:
from faster_whisper import WhisperModel
self._whisper = WhisperModel(self._whisper_model, device="cuda",
compute_type=self._compute_type, download_root="/models")
self.get_logger().info(f"Whisper '{self._whisper_model}' loaded")
except Exception as e:
self.get_logger().error(f"Whisper failed: {e}")
self._set_state(AudioState.ERROR)
self._metrics.error_msg = f"Whisper init: {e}"
return
try:
from piper import PiperVoice
self._tts_voice = PiperVoice.load(self._tts_voice_path)
self.get_logger().info("Piper TTS loaded")
except Exception as e:
self.get_logger().warn(f"Piper TTS failed: {e}")
self.get_logger().info(f"Audio pipeline ready ({time.time()-t0:.1f}s)")
self._set_state(AudioState.LISTENING)
self._publish_status()
threading.Thread(target=self._audio_loop, daemon=True).start()
def _audio_loop(self) -> None:
self._running = True
import numpy as np
while self._running and self._state != AudioState.ERROR:
raw_chunk = self._jabra.read_chunk(self._chunk_size)
if raw_chunk is None:
continue
samples = pcm16_to_float32(raw_chunk)
self._audio_buffer.push(samples)
if self._state == AudioState.LISTENING and self._oww is not None:
try:
preds = self._oww.predict(samples)
score = preds.get(self._ww_model, 0.0)
if isinstance(score, (list, tuple)):
score = score[-1]
if score >= self._ww_thresh:
self.get_logger().info(f"Wake word detected (score={score:.2f})")
self._metrics.wake_to_stt_ms = 0.0
self._set_state(AudioState.WAKE_WORD_DETECTED)
self._segmenter.reset()
self._audio_buffer.clear()
except Exception as e:
self.get_logger().debug(f"Wake word error: {e}")
if self._state == AudioState.WAKE_WORD_DETECTED:
completed = self._segmenter.push(samples)
for utt_samples, duration in completed:
threading.Thread(target=self._process_utterance,
args=(utt_samples, duration), daemon=True).start()
def _process_utterance(self, audio_samples: list, duration: float) -> None:
if self._whisper is None:
self._set_state(AudioState.LISTENING)
return
self._set_state(AudioState.PROCESSING)
t0 = time.time()
try:
import numpy as np
audio_np = np.array(audio_samples, dtype=np.float32) if isinstance(audio_samples, list) else audio_samples.astype(np.float32)
segments_gen, info = self._whisper.transcribe(audio_np, language=self._whisper_lang, beam_size=3, vad_filter=False)
text = " ".join([seg.text.strip() for seg in segments_gen]).strip()
if text:
stt_time = (time.time() - t0) * 1000
self._metrics.stt_processing_ms = stt_time
self._metrics.transcribed_text = text
self._metrics.total_latency_ms = stt_time
msg = String()
msg.data = text
self._text_pub.publish(msg)
self.get_logger().info(f"STT [{duration:.1f}s, {stt_time:.0f}ms]: '{text}'")
self._process_tts(text)
else:
self._set_state(AudioState.LISTENING)
except Exception as e:
self.get_logger().error(f"STT error: {e}")
self._metrics.error_msg = str(e)
self._set_state(AudioState.LISTENING)
def _process_tts(self, text: str) -> None:
if self._tts_voice is None:
self._set_state(AudioState.LISTENING)
return
self._set_state(AudioState.SPEAKING)
t0 = time.time()
try:
pcm_data = b"".join(self._tts_voice.synthesize_stream_raw(text))
self._metrics.tts_synthesis_ms = (time.time() - t0) * 1000
if self._tts_rate != self._sample_rate:
import numpy as np
samples = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
pcm_data = float32_to_pcm16(resample_audio(samples, self._tts_rate, self._sample_rate))
self._jabra.write_chunk(pcm_data)
self.get_logger().info(f"TTS: played {len(pcm_data)} bytes")
except Exception as e:
self.get_logger().error(f"TTS error: {e}")
self._metrics.error_msg = str(e)
finally:
self._set_state(AudioState.LISTENING)
self._publish_status()
def _set_state(self, new_state: AudioState) -> None:
with self._state_lock:
if self._state != new_state:
self._state = new_state
self.get_logger().info(f"Audio state: {new_state.value}")
msg = String()
msg.data = new_state.value
self._state_pub.publish(msg)
if self._mqtt:
try:
self._mqtt.publish(f"{self._mqtt.base_topic}/state", new_state.value)
except Exception as e:
self.get_logger().debug(f"MQTT publish failed: {e}")
def _publish_status(self) -> None:
status = {"state": self._state.value, "metrics": asdict(self._metrics), "timestamp": time.time()}
msg = String()
msg.data = json.dumps(status)
self._status_pub.publish(msg)
if self._mqtt:
try:
self._mqtt.publish(f"{self._mqtt.base_topic}/status", msg.data)
except Exception as e:
self.get_logger().debug(f"MQTT publish failed: {e}")
def destroy_node(self) -> None:
self._running = False
self._jabra.close()
if self._mqtt:
try:
self._mqtt.disconnect()
except Exception:
pass
super().destroy_node()
def main(args=None) -> None:
rclpy.init(args=args)
node = AudioPipelineNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,133 @@
"""Audio utilities for processing and buffering."""
from __future__ import annotations
from typing import Optional, Tuple, List
import threading, time
from collections import deque
from dataclasses import dataclass
import numpy as np
@dataclass
class AudioChunk:
samples: np.ndarray
timestamp: float
rms_db: float
class EnergyVAD:
"""Energy-based Voice Activity Detection."""
def __init__(self, threshold_db: float = -35.0):
self.threshold_db = threshold_db
def is_speech(self, samples: np.ndarray) -> bool:
rms = np.sqrt(np.mean(samples ** 2))
db = 20 * np.log10(rms + 1e-10)
return db > self.threshold_db
def rms_db(self, samples: np.ndarray) -> float:
rms = np.sqrt(np.mean(samples ** 2))
return 20 * np.log10(rms + 1e-10)
class UtteranceSegmenter:
"""Buffer and segment audio utterances based on energy VAD."""
def __init__(self, vad: Optional[EnergyVAD] = None, silence_duration_s: float = 0.5,
min_duration_s: float = 0.3, sample_rate: int = 16000):
self.vad = vad or EnergyVAD()
self.silence_duration_s = silence_duration_s
self.min_duration_s = min_duration_s
self.sample_rate = sample_rate
self._buffer = deque()
self._last_speech_time = 0.0
self._speech_started = False
self._lock = threading.Lock()
def push(self, samples: np.ndarray) -> List[Tuple[List[float], float]]:
completed = []
with self._lock:
now = time.time()
is_speech = self.vad.is_speech(samples)
if is_speech:
self._last_speech_time = now
self._speech_started = True
self._buffer.append(samples)
else:
self._buffer.append(samples)
if self._speech_started and now - self._last_speech_time > self.silence_duration_s:
utt_samples = self._extract_buffer()
duration = len(utt_samples) / self.sample_rate
if duration >= self.min_duration_s:
completed.append((utt_samples, duration))
self._speech_started = False
self._buffer.clear()
return completed
def _extract_buffer(self) -> List[float]:
if not self._buffer:
return []
flat = []
for s in self._buffer:
flat.extend(s.tolist() if isinstance(s, np.ndarray) else s)
return flat
def reset(self) -> None:
with self._lock:
self._buffer.clear()
self._speech_started = False
class AudioBuffer:
"""Thread-safe circular audio buffer."""
def __init__(self, capacity_s: float = 5.0, sample_rate: int = 16000):
self.capacity = int(capacity_s * sample_rate)
self.sample_rate = sample_rate
self._buffer = deque(maxlen=self.capacity)
self._lock = threading.Lock()
def push(self, samples: np.ndarray) -> None:
with self._lock:
self._buffer.extend(samples.tolist() if isinstance(samples, np.ndarray) else samples)
def extract(self, duration_s: Optional[float] = None) -> np.ndarray:
with self._lock:
samples = list(self._buffer)
if duration_s is not None:
num_samples = int(duration_s * self.sample_rate)
samples = samples[-num_samples:]
return np.array(samples, dtype=np.float32)
def clear(self) -> None:
with self._lock:
self._buffer.clear()
def size(self) -> int:
with self._lock:
return len(self._buffer)
def pcm16_to_float32(pcm_bytes: bytes) -> np.ndarray:
samples = np.frombuffer(pcm_bytes, dtype=np.int16)
return samples.astype(np.float32) / 32768.0
def float32_to_pcm16(samples: np.ndarray) -> bytes:
if isinstance(samples, list):
samples = np.array(samples, dtype=np.float32)
clipped = np.clip(samples, -1.0, 1.0)
pcm = (clipped * 32767).astype(np.int16)
return pcm.tobytes()
def resample_audio(samples: np.ndarray, orig_rate: int, target_rate: int) -> np.ndarray:
if orig_rate == target_rate:
return samples
from scipy import signal
num_samples = int(len(samples) * target_rate / orig_rate)
resampled = signal.resample(samples, num_samples)
return resampled.astype(np.float32)
def calculate_rms_db(samples: np.ndarray) -> float:
rms = np.sqrt(np.mean(samples ** 2))
return 20 * np.log10(rms + 1e-10)

View File

@ -0,0 +1,2 @@
[develop]
script_dir=$base/lib/saltybot_audio_pipeline/scripts

View File

@ -0,0 +1,21 @@
from setuptools import setup
package_name = 'saltybot_audio_pipeline'
setup(
name=package_name,
version='1.0.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/launch', ['launch/audio_pipeline.launch.py']),
('share/' + package_name + '/config', ['config/audio_pipeline_params.yaml']),
],
install_requires=['setuptools'],
zip_safe=True,
author='Salty Lab',
entry_points={
'console_scripts': [
'audio_pipeline_node = saltybot_audio_pipeline.audio_pipeline_node:main',
],
},
)

224
phone/INSTALL_MOTOR_TEST.md Normal file
View File

@ -0,0 +1,224 @@
# Motor Test Joystick Installation (Issue #513)
Quick setup guide for installing motor_test_joystick.py on Termux.
## Prerequisites
- **Android phone** with Termux installed
- **Python 3.9+** (installed via termux-bootstrap.sh)
- **ROS2 Humble** OR **Jetson bridge** running on networked Jetson Orin
## Installation
### 1. Copy script to phone
Option A: Via USB (adb)
```bash
# On your computer
adb push phone/motor_test_joystick.py /data/data/com.termux/files/home/
# Or just place in ~/saltylab-firmware/phone/ if building locally
```
Option B: Via git clone in Termux
```bash
# In Termux
cd ~
git clone https://gitea.vayrette.com/seb/saltylab-firmware.git
cd saltylab-firmware
```
### 2. Make executable
```bash
# In Termux
chmod +x ~/saltylab-firmware/phone/motor_test_joystick.py
```
### 3. Verify dependencies
**For ROS2 backend** (requires ros_core on Jetson):
```bash
# Check if ROS2 is available
python3 -c "import rclpy; print('✓ ROS2 available')" 2>/dev/null || echo "✗ ROS2 not available (use --backend websocket)"
```
**For WebSocket backend** (fallback, no dependencies):
```bash
python3 -c "import json, socket; print('✓ WebSocket dependencies available')"
```
## Quick Test
### 1. Start on phone (Termux)
**Option A: ROS2 mode** (requires Jetson ros_core running)
```bash
python3 ~/saltylab-firmware/phone/motor_test_joystick.py
```
**Option B: WebSocket mode** (if Jetson IP is 192.168.1.100)
```bash
python3 ~/saltylab-firmware/phone/motor_test_joystick.py \
--backend websocket \
--host 192.168.1.100
```
### 2. Verify on Jetson
Monitor `/cmd_vel` topic:
```bash
# On Jetson
ros2 topic echo /cmd_vel
```
You should see Twist messages (linear.x, angular.z) when moving the joystick.
### 3. Safety test
1. Move joystick forward (W key)
2. Watch `/cmd_vel` values change
3. Press spacebar (E-stop)
4. Verify velocities go to 0.0
5. Press Q to quit
6. Verify "Velocities sent to zero" message
## Setup Automation
### Auto-launch from Termux:Boot
1. Install Termux:Boot from F-Droid
2. Create startup script:
```bash
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/start_motor_test.sh << 'EOF'
#!/bin/bash
# Start motor test joystick in background
cd ~/saltylab-firmware/phone
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 &
EOF
chmod +x ~/.termux/boot/start_motor_test.sh
```
3. Next boot: app will start automatically
### Manual session management
```bash
# Start in background
nohup python3 ~/saltylab-firmware/phone/motor_test_joystick.py > ~/motor_test.log 2>&1 &
echo $! > ~/motor_test.pid
# Stop later
kill $(cat ~/motor_test.pid)
# View logs
tail -f ~/motor_test.log
```
## Configuration
### Adjust velocity limits
Conservative (default):
```bash
python3 motor_test_joystick.py # 0.1 m/s, 0.3 rad/s
```
Moderate:
```bash
python3 motor_test_joystick.py --linear-max 0.3 --angular-max 0.8
```
Aggressive:
```bash
python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.5
```
### Change Jetson address
For static IP:
```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100
```
For hostname (requires mDNS):
```bash
python3 motor_test_joystick.py --backend websocket --host saltybot.local
```
For different port:
```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 --port 8080
```
## Troubleshooting
### "ModuleNotFoundError: No module named 'curses'"
Curses should be built-in with Python. If missing:
```bash
# Unlikely needed, but just in case:
python3 -m pip install windows-curses # Windows only
# On Android/Termux, it's included
```
### "ROS2 module not found" (expected if no ros_core)
Solution: Use WebSocket backend
```bash
python3 motor_test_joystick.py --backend websocket --host <jetson-ip>
```
### Terminal display issues
- Make terminal larger (pinch-zoom)
- Reset terminal: `reset`
- Clear artifacts: `clear`
- Try external keyboard (more reliable than touch)
### No motor response
1. **Check Jetson ros_core is running:**
```bash
# On Jetson
ps aux | grep -E "ros|dcps" | grep -v grep
```
2. **Check motor bridge is subscribed to /cmd_vel:**
```bash
# On Jetson
ros2 topic echo /cmd_vel # Should see messages from phone
```
3. **Verify phone can reach Jetson:**
```bash
# In Termux
ping <jetson-ip>
nc -zv <jetson-ip> 9090 # For WebSocket mode
```
4. **Check phone ROS_DOMAIN_ID matches Jetson** (if using ROS2):
```bash
# Should match: export ROS_DOMAIN_ID=0 (default)
```
## Uninstall
```bash
# Remove script
rm ~/saltylab-firmware/phone/motor_test_joystick.py
# Remove auto-launch
rm ~/.termux/boot/start_motor_test.sh
# Stop running process (if active)
pkill -f motor_test_joystick
```
## Support
For issues, refer to:
- Main documentation: `MOTOR_TEST_JOYSTICK.md`
- Issue tracker: https://gitea.vayrette.com/seb/saltylab-firmware/issues/513
- Termux wiki: https://wiki.termux.com/

View File

@ -0,0 +1,177 @@
# Motor Test Joystick (Issue #513)
Terminal-based interactive joystick for bench testing SaltyBot motors via Termux.
## Quick Start
On phone (Termux):
```bash
# With ROS2 (default, requires ros_core running on Jetson)
python3 ~/saltylab-firmware/phone/motor_test_joystick.py
# With WebSocket (if ROS2 unavailable)
python3 ~/saltylab-firmware/phone/motor_test_joystick.py --backend websocket --host <jetson-ip>
```
## Controls
| Key | Action |
|-----|--------|
| **W** / **↑** | Forward (linear +X) |
| **S** / **↓** | Reverse (linear -X) |
| **A** / **←** | Turn left (angular +Z) |
| **D** / **→** | Turn right (angular -Z) |
| **SPACE** | E-stop toggle (hold disables motors) |
| **R** | Reset velocities to zero |
| **Q** | Quit |
## Features
### Real-Time Feedback
- Live velocity displays (linear X, angular Z)
- Velocity bar graphs with ● indicator
- Current input state (before clamping)
- Timeout warning (>500ms since last command)
- Status message line
### Safety Features
- **E-stop button** (spacebar): Instantly zeros velocity, toggle on/off
- **Timeout safety**: 500ms without command → sends zero velocity
- **Velocity ramping**: Input decays exponentially (95% per frame)
- **Conservative defaults**: 0.1 m/s linear, 0.3 rad/s angular
- **Graceful fallback**: WebSocket if ROS2 unavailable
### Dual Backend Support
- **ROS2 (primary)**: Publishes directly to `/cmd_vel` topic
- **WebSocket (fallback)**: JSON messages to Jetson bridge (port 9090)
## Usage Examples
### Standard ROS2 mode (Jetson has ros_core)
```bash
python3 motor_test_joystick.py
```
Sends Twist messages to `/cmd_vel` at ~20Hz
### WebSocket mode (fallback if no ROS2)
```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100
```
Sends JSON: `{"type": "twist", "linear_x": 0.05, "angular_z": 0.0, "timestamp": 1234567890}`
### Custom velocity limits
```bash
python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.0
```
Max forward: 0.5 m/s, max turn: 1.0 rad/s
### Combine options
```bash
python3 motor_test_joystick.py \
--backend websocket \
--host saltybot.local \
--linear-max 0.2 \
--angular-max 0.5
```
## Architecture
### MotorTestController
Main state machine:
- Manages velocity state (linear_x, angular_z)
- Handles e-stop state
- Enforces 500ms timeout
- Clamps velocities to max limits
- Sends commands to backend
### Backend Options
**ROS2Backend**: Direct Twist publisher
- Requires `geometry_msgs` / `rclpy`
- Topic: `/cmd_vel`
- Spin thread for ros2.spin()
**WebSocketBackend**: JSON over TCP socket
- No ROS2 dependencies
- Connects to Jetson:9090 (configurable)
- Fallback if ROS2 unavailable
### Curses UI
- Non-blocking input (getch timeout)
- 20Hz refresh rate
- Color-coded status (green=ok, red=estop/timeout, yellow=bars)
- Real-time velocity bars
- Exponential input decay (95% per frame)
## Terminal Requirements
- **Size**: Minimum 80×25 characters (larger is better for full feedback)
- **Colors**: 256-color support (curses.init_pair)
- **Non-blocking I/O**: ncurses.nodelay()
- **Unicode**: ● and 🛑 symbols (optional, falls back to ASCII)
### Test in Termux
```bash
stty size # Should show >= 25 lines
echo $TERM # Should be xterm-256color or similar
```
## Performance
| Metric | Value | Notes |
|--------|-------|-------|
| **UI Refresh** | 20 Hz | Non-blocking, timeout-based |
| **Command Rate** | 20 Hz | Updated per frame |
| **Timeout Safety** | 500ms | Zero velocity if no input |
| **Input Decay** | 95% per frame | Smooth ramp-down |
| **Max Linear** | 0.1 m/s (default) | Conservative for bench testing |
| **Max Angular** | 0.3 rad/s (default) | ~17°/s rotation |
## Troubleshooting
### "ROS2 module not found"
→ Run with `--backend websocket` instead
### "Connection refused" (WebSocket mode)
→ Check Jetson IP with `--host <ip>`, verify bridge listening on :9090
### Motors not responding
1. Check e-stop status (should show "✓ Inactive")
2. Verify timeout warning (>500ms = zero velocity sent)
3. Check Jetson `/cmd_vel` subscription: `ros2 topic echo /cmd_vel`
4. Verify network connectivity (WiFi/tethering)
### Terminal artifacts / display issues
- Try `reset` or `stty sane` in Termux
- Increase terminal size (pinch-zoom)
- Use `--backend websocket` (simpler UI fallback)
## Safety Checklist Before Testing
- [ ] Phone connected to Jetson (WiFi or USB tether)
- [ ] Motors disconnected or isolated (bench testing mode)
- [ ] E-stop accessible (spacebar, always reachable)
- [ ] Terminal window visible (no hide/scroll)
- [ ] Max velocities appropriate (start conservative: 0.1/0.3)
- [ ] Kill switch ready (Ctrl+C, or `ros2 topic pub --once /cmd_vel ...`)
## Future Enhancements
- [ ] Gamepad/joystick input (evdev) instead of keyboard
- [ ] Configurable button mappings
- [ ] Velocity profile presets (slow/medium/fast)
- [ ] Motor current feedback from motor driver
- [ ] Telemetry logging (CSV) for bench analysis
- [ ] Multi-motor independent control
## Related Issues
- **#420** — Termux bootstrap & Android phone node
- **#508** — Face LCD animations (separate system)
- **#512** — Autonomous arming (uses /cmd_vel via motor bridge)
## References
- [ROS2 Humble - geometry_msgs/Twist](https://docs.ros.org/en/humble/Concepts/Intermediate/About-Different-Distributions.html)
- [Termux - Python environment](https://wiki.termux.com/wiki/Python)
- [ncurses - Curses module](https://docs.python.org/3/library/curses.html)

View File

@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
motor_test_joystick.py Terminal-based joystick for motor testing (Issue #513)
Provides a curses-based interactive joystick UI for bench testing SaltyBot motors.
Sends Twist commands to /cmd_vel via ROS2 (or WebSocket fallback).
Controls:
W/A/S/D or Arrow Keys Steer/throttle (left/down/right/up)
Spacebar E-stop (hold = motors disabled)
Q Quit
Features:
- Conservative velocity defaults: 0.1 m/s linear, 0.3 rad/s angular
- Real-time velocity feedback display (current, max, min)
- 500ms timeout safety: stops motors if no command received
- Graceful fallback if ROS2 unavailable (WebSocket to Jetson)
- Terminal UI: velocity bars, input prompt, status line
"""
import curses
import threading
import time
import argparse
import sys
from dataclasses import dataclass
from typing import Optional
from enum import Enum
# Try to import ROS2; fall back to WebSocket if unavailable
try:
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
ROS2_AVAILABLE = True
except ImportError:
ROS2_AVAILABLE = False
import json
import socket
# === Constants ===
DEFAULT_LINEAR_VEL = 0.1 # m/s
DEFAULT_ANGULAR_VEL = 0.3 # rad/s
TIMEOUT_MS = 500 # ms before sending zero velocity
POLL_RATE_HZ = 20 # UI update rate
# === Data Classes ===
@dataclass
class VelocityState:
"""Current velocity state"""
linear_x: float = 0.0 # m/s
angular_z: float = 0.0 # rad/s
max_linear: float = DEFAULT_LINEAR_VEL
max_angular: float = DEFAULT_ANGULAR_VEL
estop_active: bool = False
last_command_time: float = 0.0
class ControllerBackend(Enum):
"""ROS2 or WebSocket backend"""
ROS2 = "ros2"
WEBSOCKET = "websocket"
# === ROS2 Backend ===
class MotorTestNode(Node):
"""ROS2 node for publishing Twist commands"""
def __init__(self):
super().__init__('motor_test_joystick')
self.pub = self.create_publisher(Twist, '/cmd_vel', 10)
def send_twist(self, linear_x: float, angular_z: float):
"""Publish Twist command"""
twist = Twist()
twist.linear.x = linear_x
twist.angular.z = angular_z
self.pub.publish(twist)
# === WebSocket Backend ===
class WebSocketController:
"""WebSocket client for communicating with Jetson"""
def __init__(self, host: str = "127.0.0.1", port: int = 9090):
self.host = host
self.port = port
self.sock = None
self.connected = False
self._connect()
def _connect(self):
"""Establish WebSocket connection"""
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
self.connected = True
except Exception as e:
print(f"WebSocket connection failed: {e}")
self.connected = False
def send_twist(self, linear_x: float, angular_z: float):
"""Send Twist via JSON over WebSocket"""
if not self.connected:
return
try:
msg = {
"type": "twist",
"linear_x": float(linear_x),
"angular_z": float(angular_z),
"timestamp": time.time()
}
self.sock.sendall((json.dumps(msg) + "\n").encode())
except Exception as e:
print(f"WebSocket send error: {e}")
self.connected = False
def close(self):
"""Close connection"""
if self.sock:
self.sock.close()
# === Main Controller ===
class MotorTestController:
"""Main controller for joystick UI and motor commands"""
def __init__(self, backend: ControllerBackend = ControllerBackend.ROS2):
self.backend = backend
self.state = VelocityState()
self.running = True
self.lock = threading.Lock()
# Initialize backend
if backend == ControllerBackend.ROS2 and ROS2_AVAILABLE:
rclpy.init()
self.node = MotorTestNode()
self.backend_obj = self.node
else:
self.backend_obj = WebSocketController()
# Start ROS2 spin thread if needed
if isinstance(self.backend_obj, MotorTestNode):
self.spin_thread = threading.Thread(
target=lambda: rclpy.spin(self.node),
daemon=True
)
self.spin_thread.start()
def update_velocity(self, linear_x: float, angular_z: float):
"""Update and send velocity command"""
with self.lock:
# Clamp to max velocities
linear_x = max(-self.state.max_linear, min(self.state.max_linear, linear_x))
angular_z = max(-self.state.max_angular, min(self.state.max_angular, angular_z))
# Zero out if estop active
if self.state.estop_active:
linear_x = 0.0
angular_z = 0.0
# Check timeout (500ms)
if time.time() - self.state.last_command_time > TIMEOUT_MS / 1000.0:
linear_x = 0.0
angular_z = 0.0
self.state.linear_x = linear_x
self.state.angular_z = angular_z
self.state.last_command_time = time.time()
# Send to backend
if self.backend_obj:
self.backend_obj.send_twist(linear_x, angular_z)
def set_estop(self, active: bool):
"""Set e-stop state"""
with self.lock:
self.state.estop_active = active
if active:
self.state.linear_x = 0.0
self.state.angular_z = 0.0
if self.backend_obj:
self.backend_obj.send_twist(0.0, 0.0)
def shutdown(self):
"""Clean shutdown"""
self.running = False
# Send zero velocity
self.update_velocity(0.0, 0.0)
time.sleep(0.1)
# Cleanup backend
if isinstance(self.backend_obj, MotorTestNode):
self.node.destroy_node()
rclpy.shutdown()
elif isinstance(self.backend_obj, WebSocketController):
self.backend_obj.close()
# === Curses UI ===
def run_joystick_ui(stdscr, controller: MotorTestController):
"""Main curses event loop"""
curses.curs_set(0) # Hide cursor
stdscr.nodelay(1) # Non-blocking getch()
stdscr.timeout(int(1000 / POLL_RATE_HZ)) # Refresh rate
# Color pairs
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
linear_input = 0.0
angular_input = 0.0
status_msg = "Ready for motor test. Press W/A/S/D to control, SPACE to e-stop, Q to quit."
try:
while controller.running:
# Get input
try:
key = stdscr.getch()
except:
key = -1
# Process input
if key == ord('q') or key == ord('Q'):
break
elif key == ord(' '): # Spacebar
controller.set_estop(not controller.state.estop_active)
status_msg = f"E-STOP {'ACTIVE' if controller.state.estop_active else 'INACTIVE'}"
elif key in [ord('w'), ord('W'), curses.KEY_UP]:
linear_input = min(linear_input + 0.02, DEFAULT_LINEAR_VEL)
status_msg = f"Forward: {linear_input:.2f} m/s"
elif key in [ord('s'), ord('S'), curses.KEY_DOWN]:
linear_input = max(linear_input - 0.02, -DEFAULT_LINEAR_VEL)
status_msg = f"Reverse: {linear_input:.2f} m/s"
elif key in [ord('d'), ord('D'), curses.KEY_RIGHT]:
angular_input = max(angular_input - 0.02, -DEFAULT_ANGULAR_VEL)
status_msg = f"Right: {-angular_input:.2f} rad/s"
elif key in [ord('a'), ord('A'), curses.KEY_LEFT]:
angular_input = min(angular_input + 0.02, DEFAULT_ANGULAR_VEL)
status_msg = f"Left: {angular_input:.2f} rad/s"
elif key == ord('r') or key == ord('R'):
# Reset velocities
linear_input = 0.0
angular_input = 0.0
status_msg = "Velocities reset to zero"
# Apply exponential decay to input (if no new input, ramp down)
if key == -1: # No input
linear_input *= 0.95
angular_input *= 0.95
if abs(linear_input) < 0.01:
linear_input = 0.0
if abs(angular_input) < 0.01:
angular_input = 0.0
# Send updated velocity
controller.update_velocity(linear_input, angular_input)
# Render UI
stdscr.clear()
height, width = stdscr.getmaxyx()
# Title
title = "SaltyBot Motor Test Joystick (Issue #513)"
stdscr.addstr(0, (width - len(title)) // 2, title,
curses.color_pair(1) | curses.A_BOLD)
# Status line
y = 2
estop_color = curses.color_pair(2) if controller.state.estop_active else curses.color_pair(1)
estop_text = f"E-STOP: {'🛑 ACTIVE' if controller.state.estop_active else '✓ Inactive'}"
stdscr.addstr(y, 2, estop_text, estop_color)
y += 2
# Velocity displays
stdscr.addstr(y, 2, f"Linear X: {controller.state.linear_x:+7.3f} m/s (max: {DEFAULT_LINEAR_VEL})")
y += 1
# Bar for linear
bar_width = 30
bar_fill = int((controller.state.linear_x / DEFAULT_LINEAR_VEL) * (bar_width / 2))
bar_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
bar = "[" + " " * (bar_width // 2 + bar_fill) + "" + " " * (bar_width // 2 - bar_fill) + "]"
stdscr.addstr(y, 2, bar, curses.color_pair(3))
y += 2
stdscr.addstr(y, 2, f"Angular Z: {controller.state.angular_z:+7.3f} rad/s (max: {DEFAULT_ANGULAR_VEL})")
y += 1
# Bar for angular
bar_fill = int((controller.state.angular_z / DEFAULT_ANGULAR_VEL) * (bar_width / 2))
bar_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
bar = "[" + " " * (bar_width // 2 + bar_fill) + "" + " " * (bar_width // 2 - bar_fill) + "]"
stdscr.addstr(y, 2, bar, curses.color_pair(3))
y += 2
# Command input display
stdscr.addstr(y, 2, f"Input: Linear={linear_input:+.3f} Angular={angular_input:+.3f}")
y += 2
# Controls legend
stdscr.addstr(y, 2, "Controls:")
y += 1
stdscr.addstr(y, 2, " W/↑ = Forward S/↓ = Reverse A/← = Left D/→ = Right")
y += 1
stdscr.addstr(y, 2, " SPACE = E-stop (toggle) R = Reset Q = Quit")
y += 2
# Status message
stdscr.addstr(y, 2, f"Status: {status_msg[:width-10]}", curses.color_pair(1))
y += 2
# Timeout warning
time_since_cmd = time.time() - controller.state.last_command_time
if time_since_cmd > (TIMEOUT_MS / 1000.0):
warning = f"⚠ TIMEOUT: Motors disabled ({time_since_cmd:.1f}s since last command)"
stdscr.addstr(y, 2, warning, curses.color_pair(2))
stdscr.refresh()
finally:
controller.shutdown()
# === Main Entry Point ===
def main():
parser = argparse.ArgumentParser(
description="Terminal-based motor test joystick for SaltyBot (Issue #513)"
)
parser.add_argument(
"--backend",
choices=["ros2", "websocket"],
default="ros2",
help="Communication backend (default: ros2)"
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Jetson hostname/IP (for WebSocket backend)"
)
parser.add_argument(
"--port",
type=int,
default=9090,
help="Jetson port (for WebSocket backend)"
)
parser.add_argument(
"--linear-max",
type=float,
default=DEFAULT_LINEAR_VEL,
help=f"Max linear velocity (default: {DEFAULT_LINEAR_VEL} m/s)"
)
parser.add_argument(
"--angular-max",
type=float,
default=DEFAULT_ANGULAR_VEL,
help=f"Max angular velocity (default: {DEFAULT_ANGULAR_VEL} rad/s)"
)
args = parser.parse_args()
# Select backend
backend = ControllerBackend.WEBSOCKET if args.backend == "websocket" else ControllerBackend.ROS2
# Create controller
controller = MotorTestController(backend=backend)
controller.state.max_linear = args.linear_max
controller.state.max_angular = args.angular_max
# Run UI
try:
curses.wrapper(run_joystick_ui, controller)
except KeyboardInterrupt:
controller.shutdown()
except Exception as e:
print(f"Error: {e}")
controller.shutdown()
sys.exit(1)
print("Motor test joystick closed. Velocities sent to zero.")
if __name__ == "__main__":
main()

View File

@ -333,11 +333,12 @@ int main(void) {
power_mgmt_activity();
}
/* RC CH5 kill switch: disarm immediately if RC is alive and CH5 off.
* Applies regardless of active mode (CH5 always has kill authority). */
/* RC CH5 kill switch: emergency stop if RC is alive and CH5 off.
* Issue #512: RC becomes optional override kill switch triggers estop,
* not disarm, so Jetson-armed robots remain armed when RC disconnects.
* Emergency stop kills motors immediately but allows re-arm. */
if (mode.rc_alive && !crsf_state.armed && bal.state == BALANCE_ARMED) {
safety_arm_cancel();
balance_disarm(&bal);
motor_driver_estop(&motors);
}
/* RC failsafe: disarm if signal lost AFTER RC was previously alive.
* Prevents auto-disarm in USB-only mode (crsf_state.last_rx_ms == 0). */
@ -351,7 +352,10 @@ int main(void) {
/* Tilt fault buzzer alert (one-shot on fault edge) */
safety_alert_tilt_fault(bal.state == BALANCE_TILT_FAULT);
/* RC arm/disarm via CH5 switch (CRSF) — edge detect, same hold interlock */
/* RC arm/disarm via CH5 switch (CRSF) — edge detect with hold interlock.
* Issue #512: RC becomes optional override. Falling edge only disarms if RC
* explicitly requested it (CH5 off while RC alive). RC disconnect doesn't
* disarm Jetson-controlled robots; Jetson timeout disarm (in main loop) handles it. */
{
static bool s_rc_armed_prev = false;
bool rc_armed_now = safety_rc_alive(now) && crsf_state.armed;
@ -363,11 +367,14 @@ int main(void) {
safety_arm_start(now);
}
}
if (!rc_armed_now && s_rc_armed_prev) {
/* Falling edge: cancel pending arm or disarm if already armed */
if (!rc_armed_now && s_rc_armed_prev && safety_rc_alive(now)) {
/* Falling edge with RC still alive: RC explicitly de-armed.
* Cancel pending arm or disarm if already armed. */
safety_arm_cancel();
if (bal.state == BALANCE_ARMED) balance_disarm(&bal);
}
/* Note: RC disconnect (crsf_state.last_rx_ms == 0 after being alive) is handled
* by failsafe timer below, NOT by this edge detector. */
s_rc_armed_prev = rc_armed_now;
}