Merge remote-tracking branch 'origin/sl-mechanical/issue-505-charging-dock'

# Conflicts:
#	phone/INSTALL_MOTOR_TEST.md
#	phone/MOTOR_TEST_JOYSTICK.md
#	phone/motor_test_joystick.py
This commit is contained in:
sl-jetson 2026-03-06 14:59:59 -05:00
commit 5add2cab51
17 changed files with 2501 additions and 87 deletions

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',
],
},
)

View File

@ -4,39 +4,60 @@ Quick setup guide for installing motor_test_joystick.py on Termux.
## Prerequisites ## Prerequisites
- Android phone with Termux installed - **Android phone** with Termux installed
- Python 3.9+ (installed via termux-bootstrap.sh) - **Python 3.9+** (installed via termux-bootstrap.sh)
- ROS2 Humble OR Jetson bridge running on networked Jetson Orin - **ROS2 Humble** OR **Jetson bridge** running on networked Jetson Orin
## Installation ## Installation
### 1. Copy script to phone ### 1. Copy script to phone
Option A: Via USB (adb)
```bash ```bash
# Option A: Via git # 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 ~ cd ~
git clone https://gitea.vayrette.com/seb/saltylab-firmware.git git clone https://gitea.vayrette.com/seb/saltylab-firmware.git
cd saltylab-firmware
# Option B: Via adb
adb push phone/motor_test_joystick.py /data/data/com.termux/files/home/
``` ```
### 2. Make executable ### 2. Make executable
```bash ```bash
# In Termux
chmod +x ~/saltylab-firmware/phone/motor_test_joystick.py 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 ## Quick Test
### 1. Start on phone (Termux) ### 1. Start on phone (Termux)
**ROS2 mode** (requires Jetson ros_core running): **Option A: ROS2 mode** (requires Jetson ros_core running)
```bash ```bash
python3 ~/saltylab-firmware/phone/motor_test_joystick.py python3 ~/saltylab-firmware/phone/motor_test_joystick.py
``` ```
**WebSocket mode** (if Jetson IP is 192.168.1.100): **Option B: WebSocket mode** (if Jetson IP is 192.168.1.100)
```bash ```bash
python3 ~/saltylab-firmware/phone/motor_test_joystick.py \ python3 ~/saltylab-firmware/phone/motor_test_joystick.py \
--backend websocket \ --backend websocket \
@ -45,76 +66,159 @@ python3 ~/saltylab-firmware/phone/motor_test_joystick.py \
### 2. Verify on Jetson ### 2. Verify on Jetson
Monitor `/cmd_vel` topic:
```bash ```bash
# On Jetson
ros2 topic echo /cmd_vel ros2 topic echo /cmd_vel
``` ```
You should see Twist messages (linear.x, angular.z) when moving the joystick.
### 3. Safety test ### 3. Safety test
1. Move joystick (W key) 1. Move joystick forward (W key)
2. Watch /cmd_vel values change 2. Watch `/cmd_vel` values change
3. Press spacebar (E-stop) 3. Press spacebar (E-stop)
4. Verify velocities go to 0.0 4. Verify velocities go to 0.0
5. Press Q to quit 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 ## Configuration
### Adjust velocity limits ### Adjust velocity limits
Conservative (default):
```bash ```bash
# Conservative (default)
python3 motor_test_joystick.py # 0.1 m/s, 0.3 rad/s python3 motor_test_joystick.py # 0.1 m/s, 0.3 rad/s
```
# Moderate Moderate:
```bash
python3 motor_test_joystick.py --linear-max 0.3 --angular-max 0.8 python3 motor_test_joystick.py --linear-max 0.3 --angular-max 0.8
```
# Aggressive Aggressive:
```bash
python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.5 python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.5
``` ```
### Change Jetson address ### Change Jetson address
For static IP:
```bash ```bash
# Static IP
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 python3 motor_test_joystick.py --backend websocket --host 192.168.1.100
```
# mDNS hostname For hostname (requires mDNS):
```bash
python3 motor_test_joystick.py --backend websocket --host saltybot.local python3 motor_test_joystick.py --backend websocket --host saltybot.local
```
# Different port For different port:
```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 --port 8080 python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 --port 8080
``` ```
## Troubleshooting ## Troubleshooting
### "ROS2 module not found" ### "ModuleNotFoundError: No module named 'curses'"
→ Use WebSocket backend: `--backend websocket --host <jetson-ip>`
### "Connection refused" (WebSocket mode) Curses should be built-in with Python. If missing:
→ Check Jetson IP, verify bridge listening on :9090 ```bash
# Unlikely needed, but just in case:
python3 -m pip install windows-curses # Windows only
# On Android/Termux, it's included
```
### Motors not responding ### "ROS2 module not found" (expected if no ros_core)
1. Verify e-stop status (should show "Inactive")
2. Check timeout warning (>500ms = zero velocity) Solution: Use WebSocket backend
3. Monitor Jetson: `ros2 topic echo /cmd_vel` ```bash
4. Verify network connectivity python3 motor_test_joystick.py --backend websocket --host <jetson-ip>
```
### Terminal display issues ### Terminal display issues
- Try `reset` or `stty sane` in Termux
- Increase terminal size (pinch-zoom)
- Use external keyboard (more reliable)
## Safety Checklist - Make terminal larger (pinch-zoom)
- Reset terminal: `reset`
- Clear artifacts: `clear`
- Try external keyboard (more reliable than touch)
- [ ] Phone connected to Jetson (WiFi/tether) ### No motor response
- [ ] Motors disconnected or isolated (bench testing)
- [ ] E-stop accessible (spacebar) 1. **Check Jetson ros_core is running:**
- [ ] Terminal window visible ```bash
- [ ] Max velocities appropriate (conservative defaults) # On Jetson
- [ ] Kill switch ready (Ctrl+C) 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 ## Support
For issues, refer to:
- Main documentation: `MOTOR_TEST_JOYSTICK.md` - Main documentation: `MOTOR_TEST_JOYSTICK.md`
- Issue tracker: https://gitea.vayrette.com/seb/saltylab-firmware/issues/513 - Issue tracker: https://gitea.vayrette.com/seb/saltylab-firmware/issues/513
- Termux wiki: https://wiki.termux.com/ - Termux wiki: https://wiki.termux.com/

View File

@ -4,9 +4,13 @@ Terminal-based interactive joystick for bench testing SaltyBot motors via Termux
## Quick Start ## Quick Start
On phone (Termux):
```bash ```bash
python3 motor_test_joystick.py # ROS2 mode # With ROS2 (default, requires ros_core running on Jetson)
python3 motor_test_joystick.py --backend websocket --host <jetson-ip> # WebSocket mode 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 ## Controls
@ -17,47 +21,157 @@ python3 motor_test_joystick.py --backend websocket --host <jetson-ip> # WebSock
| **S** / **↓** | Reverse (linear -X) | | **S** / **↓** | Reverse (linear -X) |
| **A** / **←** | Turn left (angular +Z) | | **A** / **←** | Turn left (angular +Z) |
| **D** / **→** | Turn right (angular -Z) | | **D** / **→** | Turn right (angular -Z) |
| **SPACE** | E-stop toggle | | **SPACE** | E-stop toggle (hold disables motors) |
| **R** | Reset velocities | | **R** | Reset velocities to zero |
| **Q** | Quit | | **Q** | Quit |
## Features ## Features
- Real-time velocity feedback with bar graphs ### Real-Time Feedback
- Spacebar e-stop (instantly zeros velocity) - Live velocity displays (linear X, angular Z)
- 500ms timeout safety (sends zero if idle) - Velocity bar graphs with ● indicator
- Conservative defaults: 0.1 m/s linear, 0.3 rad/s angular - Current input state (before clamping)
- Dual backend: ROS2 (/cmd_vel) or WebSocket - Timeout warning (>500ms since last command)
- Graceful fallback if ROS2 unavailable - 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 ## Usage Examples
### Standard ROS2 mode (Jetson has ros_core)
```bash ```bash
# Standard ROS2 mode
python3 motor_test_joystick.py python3 motor_test_joystick.py
```
Sends Twist messages to `/cmd_vel` at ~20Hz
# WebSocket mode (fallback) ### WebSocket mode (fallback if no ROS2)
```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 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 ### Custom velocity limits
```bash
python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.0 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 ## Architecture
- **MotorTestController**: State machine, velocity limiting, timeout enforcement ### MotorTestController
- **MotorTestNode** (ROS2): Twist publisher to /cmd_vel Main state machine:
- **WebSocketController** (fallback): JSON messages to Jetson - Manages velocity state (linear_x, angular_z)
- **Curses UI**: Non-blocking input, real-time feedback, status display - Handles e-stop state
- Enforces 500ms timeout
- Clamps velocities to max limits
- Sends commands to backend
## Safety ### Backend Options
- Conservative defaults (0.1/0.3 m/s) **ROS2Backend**: Direct Twist publisher
- E-stop button (spacebar) - Requires `geometry_msgs` / `rclpy`
- 500ms timeout (auto-zero velocity) - Topic: `/cmd_vel`
- Input clamping & exponential decay - 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 ## Related Issues
- #420 — Termux bootstrap & Android phone node - **#420** — Termux bootstrap & Android phone node
- #512 — Autonomous arming (uses /cmd_vel) - **#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

@ -12,9 +12,10 @@ Controls:
Features: Features:
- Conservative velocity defaults: 0.1 m/s linear, 0.3 rad/s angular - Conservative velocity defaults: 0.1 m/s linear, 0.3 rad/s angular
- Real-time velocity feedback display - Real-time velocity feedback display (current, max, min)
- 500ms timeout safety: stops motors if no command received - 500ms timeout safety: stops motors if no command received
- Terminal UI: velocity bars, status line, e-stop indicator - Graceful fallback if ROS2 unavailable (WebSocket to Jetson)
- Terminal UI: velocity bars, input prompt, status line
""" """
import curses import curses
@ -23,6 +24,7 @@ import time
import argparse import argparse
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from enum import Enum from enum import Enum
# Try to import ROS2; fall back to WebSocket if unavailable # Try to import ROS2; fall back to WebSocket if unavailable
@ -46,8 +48,8 @@ POLL_RATE_HZ = 20 # UI update rate
@dataclass @dataclass
class VelocityState: class VelocityState:
"""Current velocity state""" """Current velocity state"""
linear_x: float = 0.0 linear_x: float = 0.0 # m/s
angular_z: float = 0.0 angular_z: float = 0.0 # rad/s
max_linear: float = DEFAULT_LINEAR_VEL max_linear: float = DEFAULT_LINEAR_VEL
max_angular: float = DEFAULT_ANGULAR_VEL max_angular: float = DEFAULT_ANGULAR_VEL
estop_active: bool = False estop_active: bool = False
@ -101,6 +103,7 @@ class WebSocketController:
"""Send Twist via JSON over WebSocket""" """Send Twist via JSON over WebSocket"""
if not self.connected: if not self.connected:
return return
try: try:
msg = { msg = {
"type": "twist", "type": "twist",
@ -157,7 +160,7 @@ class MotorTestController:
linear_x = 0.0 linear_x = 0.0
angular_z = 0.0 angular_z = 0.0
# Check timeout # Check timeout (500ms)
if time.time() - self.state.last_command_time > TIMEOUT_MS / 1000.0: if time.time() - self.state.last_command_time > TIMEOUT_MS / 1000.0:
linear_x = 0.0 linear_x = 0.0
angular_z = 0.0 angular_z = 0.0
@ -183,9 +186,11 @@ class MotorTestController:
def shutdown(self): def shutdown(self):
"""Clean shutdown""" """Clean shutdown"""
self.running = False self.running = False
# Send zero velocity
self.update_velocity(0.0, 0.0) self.update_velocity(0.0, 0.0)
time.sleep(0.1) time.sleep(0.1)
# Cleanup backend
if isinstance(self.backend_obj, MotorTestNode): if isinstance(self.backend_obj, MotorTestNode):
self.node.destroy_node() self.node.destroy_node()
rclpy.shutdown() rclpy.shutdown()
@ -196,9 +201,9 @@ class MotorTestController:
# === Curses UI === # === Curses UI ===
def run_joystick_ui(stdscr, controller: MotorTestController): def run_joystick_ui(stdscr, controller: MotorTestController):
"""Main curses event loop""" """Main curses event loop"""
curses.curs_set(0) curses.curs_set(0) # Hide cursor
stdscr.nodelay(1) stdscr.nodelay(1) # Non-blocking getch()
stdscr.timeout(int(1000 / POLL_RATE_HZ)) stdscr.timeout(int(1000 / POLL_RATE_HZ)) # Refresh rate
# Color pairs # Color pairs
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
@ -207,7 +212,7 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
linear_input = 0.0 linear_input = 0.0
angular_input = 0.0 angular_input = 0.0
status_msg = "Ready. W/A/S/D=control, SPACE=estop, Q=quit" status_msg = "Ready for motor test. Press W/A/S/D to control, SPACE to e-stop, Q to quit."
try: try:
while controller.running: while controller.running:
@ -236,12 +241,13 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
angular_input = min(angular_input + 0.02, DEFAULT_ANGULAR_VEL) angular_input = min(angular_input + 0.02, DEFAULT_ANGULAR_VEL)
status_msg = f"Left: {angular_input:.2f} rad/s" status_msg = f"Left: {angular_input:.2f} rad/s"
elif key == ord('r') or key == ord('R'): elif key == ord('r') or key == ord('R'):
# Reset velocities
linear_input = 0.0 linear_input = 0.0
angular_input = 0.0 angular_input = 0.0
status_msg = "Velocities reset" status_msg = "Velocities reset to zero"
# Exponential decay # Apply exponential decay to input (if no new input, ramp down)
if key == -1: if key == -1: # No input
linear_input *= 0.95 linear_input *= 0.95
angular_input *= 0.95 angular_input *= 0.95
if abs(linear_input) < 0.01: if abs(linear_input) < 0.01:
@ -249,25 +255,29 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
if abs(angular_input) < 0.01: if abs(angular_input) < 0.01:
angular_input = 0.0 angular_input = 0.0
# Send velocity # Send updated velocity
controller.update_velocity(linear_input, angular_input) controller.update_velocity(linear_input, angular_input)
# Render UI # Render UI
stdscr.clear() stdscr.clear()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
# Title
title = "SaltyBot Motor Test Joystick (Issue #513)" title = "SaltyBot Motor Test Joystick (Issue #513)"
stdscr.addstr(0, max(0, (width - len(title)) // 2), title, stdscr.addstr(0, (width - len(title)) // 2, title,
curses.color_pair(1) | curses.A_BOLD) curses.color_pair(1) | curses.A_BOLD)
# Status line
y = 2 y = 2
estop_color = curses.color_pair(2) if controller.state.estop_active else curses.color_pair(1) 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'}" estop_text = f"E-STOP: {'🛑 ACTIVE' if controller.state.estop_active else 'Inactive'}"
stdscr.addstr(y, 2, estop_text, estop_color) stdscr.addstr(y, 2, estop_text, estop_color)
y += 2 y += 2
# Velocity displays
stdscr.addstr(y, 2, f"Linear X: {controller.state.linear_x:+7.3f} m/s (max: {DEFAULT_LINEAR_VEL})") stdscr.addstr(y, 2, f"Linear X: {controller.state.linear_x:+7.3f} m/s (max: {DEFAULT_LINEAR_VEL})")
y += 1 y += 1
# Bar for linear
bar_width = 30 bar_width = 30
bar_fill = int((controller.state.linear_x / DEFAULT_LINEAR_VEL) * (bar_width / 2)) 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_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
@ -277,19 +287,34 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
stdscr.addstr(y, 2, f"Angular Z: {controller.state.angular_z:+7.3f} rad/s (max: {DEFAULT_ANGULAR_VEL})") stdscr.addstr(y, 2, f"Angular Z: {controller.state.angular_z:+7.3f} rad/s (max: {DEFAULT_ANGULAR_VEL})")
y += 1 y += 1
# Bar for angular
bar_fill = int((controller.state.angular_z / DEFAULT_ANGULAR_VEL) * (bar_width / 2)) 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_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
bar = "[" + " " * (bar_width // 2 + bar_fill) + "" + " " * (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)) stdscr.addstr(y, 2, bar, curses.color_pair(3))
y += 2 y += 2
stdscr.addstr(y, 2, f"Input: L={linear_input:+.3f} A={angular_input:+.3f}") # Command input display
stdscr.addstr(y, 2, f"Input: Linear={linear_input:+.3f} Angular={angular_input:+.3f}")
y += 2 y += 2
stdscr.addstr(y, 2, "W/↑=Forward S/↓=Reverse A/←=Left D/→=Right SPACE=E-stop R=Reset Q=Quit") # 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 y += 2
# Status message
stdscr.addstr(y, 2, f"Status: {status_msg[:width-10]}", curses.color_pair(1)) 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() stdscr.refresh()
@ -302,25 +327,47 @@ def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Terminal-based motor test joystick for SaltyBot (Issue #513)" description="Terminal-based motor test joystick for SaltyBot (Issue #513)"
) )
parser.add_argument("--backend", choices=["ros2", "websocket"], default="ros2", parser.add_argument(
help="Communication backend (default: ros2)") "--backend",
parser.add_argument("--host", default="127.0.0.1", choices=["ros2", "websocket"],
help="Jetson hostname/IP (for WebSocket backend)") default="ros2",
parser.add_argument("--port", type=int, default=9090, help="Communication backend (default: ros2)"
help="Jetson port (for WebSocket backend)") )
parser.add_argument("--linear-max", type=float, default=DEFAULT_LINEAR_VEL, parser.add_argument(
help=f"Max linear velocity (default: {DEFAULT_LINEAR_VEL} m/s)") "--host",
parser.add_argument("--angular-max", type=float, default=DEFAULT_ANGULAR_VEL, default="127.0.0.1",
help=f"Max angular velocity (default: {DEFAULT_ANGULAR_VEL} rad/s)") 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() args = parser.parse_args()
# Select backend
backend = ControllerBackend.WEBSOCKET if args.backend == "websocket" else ControllerBackend.ROS2 backend = ControllerBackend.WEBSOCKET if args.backend == "websocket" else ControllerBackend.ROS2
# Create controller
controller = MotorTestController(backend=backend) controller = MotorTestController(backend=backend)
controller.state.max_linear = args.linear_max controller.state.max_linear = args.linear_max
controller.state.max_angular = args.angular_max controller.state.max_angular = args.angular_max
# Run UI
try: try:
curses.wrapper(run_joystick_ui, controller) curses.wrapper(run_joystick_ui, controller)
except KeyboardInterrupt: except KeyboardInterrupt: