diff --git a/chassis/ISSUE_505_CHARGING_DOCK_24V_DESIGN.md b/chassis/ISSUE_505_CHARGING_DOCK_24V_DESIGN.md
new file mode 100644
index 0000000..cb1eb6b
--- /dev/null
+++ b/chassis/ISSUE_505_CHARGING_DOCK_24V_DESIGN.md
@@ -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 mΩ (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 | ~$40–60 | ~$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, 5–8 mm cable | 2 | ~$3 | ~$6 | AliExpress, Heilind |
+| E4 | Varistor (MOV) | 18–28V, 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 | ~$8–12 | ~$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 |
+| R1–R4 | 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, 18–28V) 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 5–6: 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: 0–15A (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 mΩ (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.5–24.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
diff --git a/chassis/charging_dock_505.scad b/chassis/charging_dock_505.scad
new file mode 100644
index 0000000..316d89b
--- /dev/null
+++ b/chassis/charging_dock_505.scad
@@ -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 (L→R): 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);
+ }
+}
diff --git a/chassis/charging_dock_505_BOM.csv b/chassis/charging_dock_505_BOM.csv
new file mode 100644
index 0000000..f3a9f1b
--- /dev/null
+++ b/chassis/charging_dock_505_BOM.csv
@@ -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
diff --git a/chassis/charging_dock_receiver_505.scad b/chassis/charging_dock_receiver_505.scad
new file mode 100644
index 0000000..34ab180
--- /dev/null
+++ b/chassis/charging_dock_receiver_505.scad
@@ -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);
+ }
+ }
+}
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/README.md b/jetson/ros2_ws/src/saltybot_audio_pipeline/README.md
new file mode 100644
index 0000000..9074238
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/README.md
@@ -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
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/config/audio_pipeline_params.yaml b/jetson/ros2_ws/src/saltybot_audio_pipeline/config/audio_pipeline_params.yaml
new file mode 100644
index 0000000..ad72268
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/config/audio_pipeline_params.yaml
@@ -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"
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/launch/audio_pipeline.launch.py b/jetson/ros2_ws/src/saltybot_audio_pipeline/launch/audio_pipeline.launch.py
new file mode 100644
index 0000000..5dd06b1
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/launch/audio_pipeline.launch.py
@@ -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,
+ ),
+ ])
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/package.xml b/jetson/ros2_ws/src/saltybot_audio_pipeline/package.xml
new file mode 100644
index 0000000..b5bee53
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/package.xml
@@ -0,0 +1,12 @@
+
+
+ saltybot_audio_pipeline
+ 1.0.0
+ Full audio pipeline: Jabra SPEAK 810, wake word, STT, TTS with MQTT (Issue #503)
+ Salty Lab
+ Apache-2.0
+ ament_python
+ rclpy
+ std_msgs
+ pytest
+
\ No newline at end of file
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/resource/saltybot_audio_pipeline b/jetson/ros2_ws/src/saltybot_audio_pipeline/resource/saltybot_audio_pipeline
new file mode 100644
index 0000000..e69de29
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/__init__.py b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/__init__.py
new file mode 100644
index 0000000..f6ffb99
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/__init__.py
@@ -0,0 +1,2 @@
+"""Audio pipeline for Salty Bot."""
+__version__ = "1.0.0"
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_pipeline_node.py b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_pipeline_node.py
new file mode 100644
index 0000000..0cc9cdc
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_pipeline_node.py
@@ -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()
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_utils.py b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_utils.py
new file mode 100644
index 0000000..b00f6ba
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/saltybot_audio_pipeline/audio_utils.py
@@ -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)
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.cfg b/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.cfg
new file mode 100644
index 0000000..633b438
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.cfg
@@ -0,0 +1,2 @@
+[develop]
+script_dir=$base/lib/saltybot_audio_pipeline/scripts
\ No newline at end of file
diff --git a/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.py b/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.py
new file mode 100644
index 0000000..214aa20
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_audio_pipeline/setup.py
@@ -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',
+ ],
+ },
+)
\ No newline at end of file
diff --git a/phone/INSTALL_MOTOR_TEST.md b/phone/INSTALL_MOTOR_TEST.md
index 3b71eaa..d304a0f 100644
--- a/phone/INSTALL_MOTOR_TEST.md
+++ b/phone/INSTALL_MOTOR_TEST.md
@@ -4,39 +4,60 @@ Quick setup guide for installing motor_test_joystick.py on Termux.
## Prerequisites
-- Android phone with Termux installed
-- Python 3.9+ (installed via termux-bootstrap.sh)
-- ROS2 Humble OR Jetson bridge running on networked Jetson Orin
+- **Android phone** with Termux installed
+- **Python 3.9+** (installed via termux-bootstrap.sh)
+- **ROS2 Humble** OR **Jetson bridge** running on networked Jetson Orin
## Installation
### 1. Copy script to phone
+Option A: Via USB (adb)
```bash
-# 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 ~
git clone https://gitea.vayrette.com/seb/saltylab-firmware.git
-
-# Option B: Via adb
-adb push phone/motor_test_joystick.py /data/data/com.termux/files/home/
+cd saltylab-firmware
```
### 2. Make executable
```bash
+# In Termux
chmod +x ~/saltylab-firmware/phone/motor_test_joystick.py
```
+### 3. Verify dependencies
+
+**For ROS2 backend** (requires ros_core on Jetson):
+```bash
+# Check if ROS2 is available
+python3 -c "import rclpy; print('✓ ROS2 available')" 2>/dev/null || echo "✗ ROS2 not available (use --backend websocket)"
+```
+
+**For WebSocket backend** (fallback, no dependencies):
+```bash
+python3 -c "import json, socket; print('✓ WebSocket dependencies available')"
+```
+
## Quick Test
### 1. Start on phone (Termux)
-**ROS2 mode** (requires Jetson ros_core running):
+**Option A: ROS2 mode** (requires Jetson ros_core running)
```bash
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
python3 ~/saltylab-firmware/phone/motor_test_joystick.py \
--backend websocket \
@@ -45,76 +66,159 @@ python3 ~/saltylab-firmware/phone/motor_test_joystick.py \
### 2. Verify on Jetson
+Monitor `/cmd_vel` topic:
```bash
+# On Jetson
ros2 topic echo /cmd_vel
```
+You should see Twist messages (linear.x, angular.z) when moving the joystick.
+
### 3. Safety test
-1. Move joystick (W key)
-2. Watch /cmd_vel values change
+1. Move joystick forward (W key)
+2. Watch `/cmd_vel` values change
3. Press spacebar (E-stop)
4. Verify velocities go to 0.0
5. Press Q to quit
+6. Verify "Velocities sent to zero" message
+
+## Setup Automation
+
+### Auto-launch from Termux:Boot
+
+1. Install Termux:Boot from F-Droid
+2. Create startup script:
+ ```bash
+ mkdir -p ~/.termux/boot
+ cat > ~/.termux/boot/start_motor_test.sh << 'EOF'
+ #!/bin/bash
+ # Start motor test joystick in background
+ cd ~/saltylab-firmware/phone
+ python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 &
+ EOF
+ chmod +x ~/.termux/boot/start_motor_test.sh
+ ```
+
+3. Next boot: app will start automatically
+
+### Manual session management
+
+```bash
+# Start in background
+nohup python3 ~/saltylab-firmware/phone/motor_test_joystick.py > ~/motor_test.log 2>&1 &
+echo $! > ~/motor_test.pid
+
+# Stop later
+kill $(cat ~/motor_test.pid)
+
+# View logs
+tail -f ~/motor_test.log
+```
## Configuration
### Adjust velocity limits
+Conservative (default):
```bash
-# Conservative (default)
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
+```
-# Aggressive
+Aggressive:
+```bash
python3 motor_test_joystick.py --linear-max 0.5 --angular-max 1.5
```
### Change Jetson address
+For static IP:
```bash
-# Static IP
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
+```
-# Different port
+For different port:
+```bash
python3 motor_test_joystick.py --backend websocket --host 192.168.1.100 --port 8080
```
## Troubleshooting
-### "ROS2 module not found"
-→ Use WebSocket backend: `--backend websocket --host `
+### "ModuleNotFoundError: No module named 'curses'"
-### "Connection refused" (WebSocket mode)
-→ Check Jetson IP, verify bridge listening on :9090
+Curses should be built-in with Python. If missing:
+```bash
+# Unlikely needed, but just in case:
+python3 -m pip install windows-curses # Windows only
+# On Android/Termux, it's included
+```
-### Motors not responding
-1. Verify e-stop status (should show "Inactive")
-2. Check timeout warning (>500ms = zero velocity)
-3. Monitor Jetson: `ros2 topic echo /cmd_vel`
-4. Verify network connectivity
+### "ROS2 module not found" (expected if no ros_core)
+
+Solution: Use WebSocket backend
+```bash
+python3 motor_test_joystick.py --backend websocket --host
+```
### 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)
-- [ ] Motors disconnected or isolated (bench testing)
-- [ ] E-stop accessible (spacebar)
-- [ ] Terminal window visible
-- [ ] Max velocities appropriate (conservative defaults)
-- [ ] Kill switch ready (Ctrl+C)
+### No motor response
+
+1. **Check Jetson ros_core is running:**
+ ```bash
+ # On Jetson
+ ps aux | grep -E "ros|dcps" | grep -v grep
+ ```
+
+2. **Check motor bridge is subscribed to /cmd_vel:**
+ ```bash
+ # On Jetson
+ ros2 topic echo /cmd_vel # Should see messages from phone
+ ```
+
+3. **Verify phone can reach Jetson:**
+ ```bash
+ # In Termux
+ ping
+ nc -zv 9090 # For WebSocket mode
+ ```
+
+4. **Check phone ROS_DOMAIN_ID matches Jetson** (if using ROS2):
+ ```bash
+ # Should match: export ROS_DOMAIN_ID=0 (default)
+ ```
+
+## Uninstall
+
+```bash
+# Remove script
+rm ~/saltylab-firmware/phone/motor_test_joystick.py
+
+# Remove auto-launch
+rm ~/.termux/boot/start_motor_test.sh
+
+# Stop running process (if active)
+pkill -f motor_test_joystick
+```
## Support
+For issues, refer to:
- Main documentation: `MOTOR_TEST_JOYSTICK.md`
- Issue tracker: https://gitea.vayrette.com/seb/saltylab-firmware/issues/513
- Termux wiki: https://wiki.termux.com/
diff --git a/phone/MOTOR_TEST_JOYSTICK.md b/phone/MOTOR_TEST_JOYSTICK.md
index 95a02e2..15e0156 100644
--- a/phone/MOTOR_TEST_JOYSTICK.md
+++ b/phone/MOTOR_TEST_JOYSTICK.md
@@ -4,9 +4,13 @@ Terminal-based interactive joystick for bench testing SaltyBot motors via Termux
## Quick Start
+On phone (Termux):
```bash
-python3 motor_test_joystick.py # ROS2 mode
-python3 motor_test_joystick.py --backend websocket --host # WebSocket mode
+# With ROS2 (default, requires ros_core running on Jetson)
+python3 ~/saltylab-firmware/phone/motor_test_joystick.py
+
+# With WebSocket (if ROS2 unavailable)
+python3 ~/saltylab-firmware/phone/motor_test_joystick.py --backend websocket --host
```
## Controls
@@ -17,47 +21,157 @@ python3 motor_test_joystick.py --backend websocket --host # WebSock
| **S** / **↓** | Reverse (linear -X) |
| **A** / **←** | Turn left (angular +Z) |
| **D** / **→** | Turn right (angular -Z) |
-| **SPACE** | E-stop toggle |
-| **R** | Reset velocities |
+| **SPACE** | E-stop toggle (hold disables motors) |
+| **R** | Reset velocities to zero |
| **Q** | Quit |
## Features
-- Real-time velocity feedback with bar graphs
-- Spacebar e-stop (instantly zeros velocity)
-- 500ms timeout safety (sends zero if idle)
-- Conservative defaults: 0.1 m/s linear, 0.3 rad/s angular
-- Dual backend: ROS2 (/cmd_vel) or WebSocket
-- Graceful fallback if ROS2 unavailable
+### Real-Time Feedback
+- Live velocity displays (linear X, angular Z)
+- Velocity bar graphs with ● indicator
+- Current input state (before clamping)
+- Timeout warning (>500ms since last command)
+- Status message line
+
+### Safety Features
+- **E-stop button** (spacebar): Instantly zeros velocity, toggle on/off
+- **Timeout safety**: 500ms without command → sends zero velocity
+- **Velocity ramping**: Input decays exponentially (95% per frame)
+- **Conservative defaults**: 0.1 m/s linear, 0.3 rad/s angular
+- **Graceful fallback**: WebSocket if ROS2 unavailable
+
+### Dual Backend Support
+- **ROS2 (primary)**: Publishes directly to `/cmd_vel` topic
+- **WebSocket (fallback)**: JSON messages to Jetson bridge (port 9090)
## Usage Examples
+### Standard ROS2 mode (Jetson has ros_core)
```bash
-# Standard ROS2 mode
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
+```
+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
```
+Max forward: 0.5 m/s, max turn: 1.0 rad/s
+
+### Combine options
+```bash
+python3 motor_test_joystick.py \
+ --backend websocket \
+ --host saltybot.local \
+ --linear-max 0.2 \
+ --angular-max 0.5
+```
## Architecture
-- **MotorTestController**: State machine, velocity limiting, timeout enforcement
-- **MotorTestNode** (ROS2): Twist publisher to /cmd_vel
-- **WebSocketController** (fallback): JSON messages to Jetson
-- **Curses UI**: Non-blocking input, real-time feedback, status display
+### MotorTestController
+Main state machine:
+- Manages velocity state (linear_x, angular_z)
+- Handles e-stop state
+- Enforces 500ms timeout
+- Clamps velocities to max limits
+- Sends commands to backend
-## Safety
+### Backend Options
-- Conservative defaults (0.1/0.3 m/s)
-- E-stop button (spacebar)
-- 500ms timeout (auto-zero velocity)
-- Input clamping & exponential decay
+**ROS2Backend**: Direct Twist publisher
+- Requires `geometry_msgs` / `rclpy`
+- Topic: `/cmd_vel`
+- Spin thread for ros2.spin()
+
+**WebSocketBackend**: JSON over TCP socket
+- No ROS2 dependencies
+- Connects to Jetson:9090 (configurable)
+- Fallback if ROS2 unavailable
+
+### Curses UI
+- Non-blocking input (getch timeout)
+- 20Hz refresh rate
+- Color-coded status (green=ok, red=estop/timeout, yellow=bars)
+- Real-time velocity bars
+- Exponential input decay (95% per frame)
+
+## Terminal Requirements
+
+- **Size**: Minimum 80×25 characters (larger is better for full feedback)
+- **Colors**: 256-color support (curses.init_pair)
+- **Non-blocking I/O**: ncurses.nodelay()
+- **Unicode**: ● and 🛑 symbols (optional, falls back to ASCII)
+
+### Test in Termux
+```bash
+stty size # Should show >= 25 lines
+echo $TERM # Should be xterm-256color or similar
+```
+
+## Performance
+
+| Metric | Value | Notes |
+|--------|-------|-------|
+| **UI Refresh** | 20 Hz | Non-blocking, timeout-based |
+| **Command Rate** | 20 Hz | Updated per frame |
+| **Timeout Safety** | 500ms | Zero velocity if no input |
+| **Input Decay** | 95% per frame | Smooth ramp-down |
+| **Max Linear** | 0.1 m/s (default) | Conservative for bench testing |
+| **Max Angular** | 0.3 rad/s (default) | ~17°/s rotation |
+
+## Troubleshooting
+
+### "ROS2 module not found"
+→ Run with `--backend websocket` instead
+
+### "Connection refused" (WebSocket mode)
+→ Check Jetson IP with `--host `, verify bridge listening on :9090
+
+### Motors not responding
+1. Check e-stop status (should show "✓ Inactive")
+2. Verify timeout warning (>500ms = zero velocity sent)
+3. Check Jetson `/cmd_vel` subscription: `ros2 topic echo /cmd_vel`
+4. Verify network connectivity (WiFi/tethering)
+
+### Terminal artifacts / display issues
+- Try `reset` or `stty sane` in Termux
+- Increase terminal size (pinch-zoom)
+- Use `--backend websocket` (simpler UI fallback)
+
+## Safety Checklist Before Testing
+
+- [ ] Phone connected to Jetson (WiFi or USB tether)
+- [ ] Motors disconnected or isolated (bench testing mode)
+- [ ] E-stop accessible (spacebar, always reachable)
+- [ ] Terminal window visible (no hide/scroll)
+- [ ] Max velocities appropriate (start conservative: 0.1/0.3)
+- [ ] Kill switch ready (Ctrl+C, or `ros2 topic pub --once /cmd_vel ...`)
+
+## Future Enhancements
+
+- [ ] Gamepad/joystick input (evdev) instead of keyboard
+- [ ] Configurable button mappings
+- [ ] Velocity profile presets (slow/medium/fast)
+- [ ] Motor current feedback from motor driver
+- [ ] Telemetry logging (CSV) for bench analysis
+- [ ] Multi-motor independent control
## Related Issues
-- #420 — Termux bootstrap & Android phone node
-- #512 — Autonomous arming (uses /cmd_vel)
+- **#420** — Termux bootstrap & Android phone node
+- **#508** — Face LCD animations (separate system)
+- **#512** — Autonomous arming (uses /cmd_vel via motor bridge)
+
+## References
+
+- [ROS2 Humble - geometry_msgs/Twist](https://docs.ros.org/en/humble/Concepts/Intermediate/About-Different-Distributions.html)
+- [Termux - Python environment](https://wiki.termux.com/wiki/Python)
+- [ncurses - Curses module](https://docs.python.org/3/library/curses.html)
diff --git a/phone/motor_test_joystick.py b/phone/motor_test_joystick.py
index 437840a..6027b9d 100644
--- a/phone/motor_test_joystick.py
+++ b/phone/motor_test_joystick.py
@@ -12,9 +12,10 @@ Controls:
Features:
- 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
- - 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
@@ -23,6 +24,7 @@ import time
import argparse
import sys
from dataclasses import dataclass
+from typing import Optional
from enum import Enum
# Try to import ROS2; fall back to WebSocket if unavailable
@@ -46,8 +48,8 @@ POLL_RATE_HZ = 20 # UI update rate
@dataclass
class VelocityState:
"""Current velocity state"""
- linear_x: float = 0.0
- angular_z: float = 0.0
+ linear_x: float = 0.0 # m/s
+ angular_z: float = 0.0 # rad/s
max_linear: float = DEFAULT_LINEAR_VEL
max_angular: float = DEFAULT_ANGULAR_VEL
estop_active: bool = False
@@ -101,6 +103,7 @@ class WebSocketController:
"""Send Twist via JSON over WebSocket"""
if not self.connected:
return
+
try:
msg = {
"type": "twist",
@@ -157,7 +160,7 @@ class MotorTestController:
linear_x = 0.0
angular_z = 0.0
- # Check timeout
+ # Check timeout (500ms)
if time.time() - self.state.last_command_time > TIMEOUT_MS / 1000.0:
linear_x = 0.0
angular_z = 0.0
@@ -183,9 +186,11 @@ class MotorTestController:
def shutdown(self):
"""Clean shutdown"""
self.running = False
+ # Send zero velocity
self.update_velocity(0.0, 0.0)
time.sleep(0.1)
+ # Cleanup backend
if isinstance(self.backend_obj, MotorTestNode):
self.node.destroy_node()
rclpy.shutdown()
@@ -196,9 +201,9 @@ class MotorTestController:
# === Curses UI ===
def run_joystick_ui(stdscr, controller: MotorTestController):
"""Main curses event loop"""
- curses.curs_set(0)
- stdscr.nodelay(1)
- stdscr.timeout(int(1000 / POLL_RATE_HZ))
+ curses.curs_set(0) # Hide cursor
+ stdscr.nodelay(1) # Non-blocking getch()
+ stdscr.timeout(int(1000 / POLL_RATE_HZ)) # Refresh rate
# Color pairs
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
@@ -207,7 +212,7 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
linear_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:
while controller.running:
@@ -236,12 +241,13 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
angular_input = min(angular_input + 0.02, DEFAULT_ANGULAR_VEL)
status_msg = f"Left: {angular_input:.2f} rad/s"
elif key == ord('r') or key == ord('R'):
+ # Reset velocities
linear_input = 0.0
angular_input = 0.0
- status_msg = "Velocities reset"
+ status_msg = "Velocities reset to zero"
- # Exponential decay
- if key == -1:
+ # Apply exponential decay to input (if no new input, ramp down)
+ if key == -1: # No input
linear_input *= 0.95
angular_input *= 0.95
if abs(linear_input) < 0.01:
@@ -249,25 +255,29 @@ def run_joystick_ui(stdscr, controller: MotorTestController):
if abs(angular_input) < 0.01:
angular_input = 0.0
- # Send velocity
+ # Send updated velocity
controller.update_velocity(linear_input, angular_input)
# Render UI
stdscr.clear()
height, width = stdscr.getmaxyx()
+ # Title
title = "SaltyBot Motor Test Joystick (Issue #513)"
- stdscr.addstr(0, max(0, (width - len(title)) // 2), title,
+ stdscr.addstr(0, (width - len(title)) // 2, title,
curses.color_pair(1) | curses.A_BOLD)
+ # Status line
y = 2
estop_color = curses.color_pair(2) if controller.state.estop_active else curses.color_pair(1)
- estop_text = f"E-STOP: {'ACTIVE' if controller.state.estop_active else 'Inactive'}"
+ estop_text = f"E-STOP: {'🛑 ACTIVE' if controller.state.estop_active else '✓ Inactive'}"
stdscr.addstr(y, 2, estop_text, estop_color)
y += 2
+ # Velocity displays
stdscr.addstr(y, 2, f"Linear X: {controller.state.linear_x:+7.3f} m/s (max: {DEFAULT_LINEAR_VEL})")
y += 1
+ # Bar for linear
bar_width = 30
bar_fill = int((controller.state.linear_x / DEFAULT_LINEAR_VEL) * (bar_width / 2))
bar_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
@@ -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})")
y += 1
+ # Bar for angular
bar_fill = int((controller.state.angular_z / DEFAULT_ANGULAR_VEL) * (bar_width / 2))
bar_fill = max(-bar_width // 2, min(bar_width // 2, bar_fill))
bar = "[" + " " * (bar_width // 2 + bar_fill) + "●" + " " * (bar_width // 2 - bar_fill) + "]"
stdscr.addstr(y, 2, bar, curses.color_pair(3))
y += 2
- 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
- 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
+ # Status message
stdscr.addstr(y, 2, f"Status: {status_msg[:width-10]}", curses.color_pair(1))
+ y += 2
+
+ # Timeout warning
+ time_since_cmd = time.time() - controller.state.last_command_time
+ if time_since_cmd > (TIMEOUT_MS / 1000.0):
+ warning = f"⚠ TIMEOUT: Motors disabled ({time_since_cmd:.1f}s since last command)"
+ stdscr.addstr(y, 2, warning, curses.color_pair(2))
stdscr.refresh()
@@ -302,25 +327,47 @@ def main():
parser = argparse.ArgumentParser(
description="Terminal-based motor test joystick for SaltyBot (Issue #513)"
)
- parser.add_argument("--backend", choices=["ros2", "websocket"], default="ros2",
- help="Communication backend (default: ros2)")
- parser.add_argument("--host", default="127.0.0.1",
- help="Jetson hostname/IP (for WebSocket backend)")
- parser.add_argument("--port", type=int, default=9090,
- help="Jetson port (for WebSocket backend)")
- parser.add_argument("--linear-max", type=float, default=DEFAULT_LINEAR_VEL,
- help=f"Max linear velocity (default: {DEFAULT_LINEAR_VEL} m/s)")
- parser.add_argument("--angular-max", type=float, default=DEFAULT_ANGULAR_VEL,
- help=f"Max angular velocity (default: {DEFAULT_ANGULAR_VEL} rad/s)")
+ parser.add_argument(
+ "--backend",
+ choices=["ros2", "websocket"],
+ default="ros2",
+ help="Communication backend (default: ros2)"
+ )
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Jetson hostname/IP (for WebSocket backend)"
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=9090,
+ help="Jetson port (for WebSocket backend)"
+ )
+ parser.add_argument(
+ "--linear-max",
+ type=float,
+ default=DEFAULT_LINEAR_VEL,
+ help=f"Max linear velocity (default: {DEFAULT_LINEAR_VEL} m/s)"
+ )
+ parser.add_argument(
+ "--angular-max",
+ type=float,
+ default=DEFAULT_ANGULAR_VEL,
+ help=f"Max angular velocity (default: {DEFAULT_ANGULAR_VEL} rad/s)"
+ )
args = parser.parse_args()
+ # Select backend
backend = ControllerBackend.WEBSOCKET if args.backend == "websocket" else ControllerBackend.ROS2
+ # Create controller
controller = MotorTestController(backend=backend)
controller.state.max_linear = args.linear_max
controller.state.max_angular = args.angular_max
+ # Run UI
try:
curses.wrapper(run_joystick_ui, controller)
except KeyboardInterrupt: