Compare commits
5 Commits
892d0a2089
...
5add2cab51
| Author | SHA1 | Date | |
|---|---|---|---|
| 5add2cab51 | |||
| e4116dffc0 | |||
| b0c2b5564d | |||
| 6f3dd46285 | |||
| 5cea0812d5 |
619
chassis/ISSUE_505_CHARGING_DOCK_24V_DESIGN.md
Normal file
619
chassis/ISSUE_505_CHARGING_DOCK_24V_DESIGN.md
Normal file
@ -0,0 +1,619 @@
|
||||
# Issue #505: 24V Charging Dock Hardware Design
|
||||
|
||||
**Agent:** sl-mechanical
|
||||
**Status:** In Progress
|
||||
**Date Started:** 2026-03-06
|
||||
**Related Issues:** #159 (5V dock), #489 (docking node)
|
||||
|
||||
---
|
||||
|
||||
## Design Overview
|
||||
|
||||
Upgraded charging dock system for 24V DC power delivery with improved reliability, higher power capacity, and integrated ArUco marker (ID 42) for precision alignment.
|
||||
|
||||
### Key Specifications
|
||||
|
||||
| Parameter | Specification | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| **Voltage** | 24 V DC | Upgrade from 5V (Issue #159) |
|
||||
| **Power capacity** | 480 W (20 A @ 24V) | Supports battery charging + auxiliary systems |
|
||||
| **Contact type** | Spring-loaded brass pads (Ø12 mm, 2 pads) | 20 mm CL-to-CL spacing |
|
||||
| **Alignment method** | V-channel rails + ArUco marker ID 42 | Precision ±15 mm tolerance |
|
||||
| **Docking nodes** | Compatible with Issue #489 (ROS2 docking node) | MQTT status reporting |
|
||||
| **Frame material** | PETG (3D-printable) | All parts exportable as STL |
|
||||
| **Contact height** | 35 mm above dock floor (configurable per robot) | Same as Issue #159 |
|
||||
|
||||
---
|
||||
|
||||
## Subsystem Design
|
||||
|
||||
### A. Power Distribution
|
||||
|
||||
#### PSU Selection (24V upgrade)
|
||||
|
||||
**Primary:** Mean Well IRM-240-24 or equivalent
|
||||
- 240W / 10A @ 24V, open frame
|
||||
- Input: 100-240V AC 50/60Hz
|
||||
- Output: 24V ±5% regulated
|
||||
- Recommended alternatives:
|
||||
- HLK-240M24 (Hi-Link, 240W, compact)
|
||||
- RECOM R-120-24 (half-power option, 120W)
|
||||
- TDK-Lambda DRB-240-24 (industrial grade)
|
||||
|
||||
**Specifications:**
|
||||
- PCB-mount or chassis-mount (via aluminum bracket)
|
||||
- 2× PG7 cable glands for AC input + 24V output
|
||||
- Thermal shutdown at 70°C (add heatsink if needed)
|
||||
|
||||
#### Power Delivery Cables
|
||||
|
||||
| Component | Spec | Notes |
|
||||
|-----------|------|-------|
|
||||
| PSU to pogo pins | 12 AWG silicone wire (red/black) | 600V rated, max 20A |
|
||||
| Cable gland exits | PG7, M20 thread, 5-8 mm cable | IP67 rated |
|
||||
| Strain relief | Silicone sleeve, 5 mm ID | 150 mm sections at terminations |
|
||||
| Crimp terminals | M3/M4 ring lug, 12 AWG | Solder + crimped (both) |
|
||||
|
||||
#### Contact Resistance & Safety
|
||||
|
||||
- **Target contact resistance:** <50 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
|
||||
531
chassis/charging_dock_505.scad
Normal file
531
chassis/charging_dock_505.scad
Normal file
@ -0,0 +1,531 @@
|
||||
// ============================================================
|
||||
// charging_dock_505.scad — 24V Charging Dock Station
|
||||
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
|
||||
// ============================================================
|
||||
//
|
||||
// 24V upgraded dock (forked from Issue #159 5V design).
|
||||
// Robot drives forward into V-guide funnel; spring-loaded pogo pins
|
||||
// make contact with the robot receiver plate (charging_dock_receiver.scad).
|
||||
//
|
||||
// Power: 24 V / 10 A (240 W) via 2× high-current pogo pins (+/-)
|
||||
// Alignment tolerance: ±20 mm lateral (V-guide funnels to centre)
|
||||
//
|
||||
// Dock architecture (top view):
|
||||
//
|
||||
// ┌─────────────────────────────────┐ ← back wall (robot stops here)
|
||||
// │ PSU shelf │
|
||||
// │ [PSU] [LED ×4] │
|
||||
// │ [POGO+][POGO-] │ ← pogo face (robot contact)
|
||||
// └────\ /────────┘
|
||||
// \ V-guide rails /
|
||||
// \ /
|
||||
// ╲ ╱ ← dock entry, ±20 mm funnel
|
||||
//
|
||||
// Components (this file):
|
||||
// Part A — dock_base() weighted base plate with ballast pockets
|
||||
// Part B — back_wall() upright back panel + pogo housing + LED bezel
|
||||
// Part C — guide_rail(side) V-funnel guide rail, L/R (print 2×)
|
||||
// Part D — aruco_mount() ArUco marker frame at dock entrance
|
||||
// Part E — psu_bracket() PSU retention bracket (rear of base)
|
||||
// Part F — led_bezel() 4-LED status bezel
|
||||
//
|
||||
// Robot-side receiver → see charging_dock_receiver.scad
|
||||
//
|
||||
// Coordinate system:
|
||||
// Z = 0 at dock floor (base plate top face)
|
||||
// Y = 0 at back wall front face (robot approaches from +Y)
|
||||
// X = 0 at dock centre
|
||||
// Robot drives in -Y direction to dock.
|
||||
//
|
||||
// RENDER options:
|
||||
// "assembly" full dock preview (default)
|
||||
// "base_stl" base plate (print 1×)
|
||||
// "back_wall_stl" back wall + pogo housing (print 1×)
|
||||
// "guide_rail_stl" V-guide rail (print 2×, mirror for R side)
|
||||
// "aruco_mount_stl" ArUco marker frame (print 1×)
|
||||
// "psu_bracket_stl" PSU mounting bracket (print 1×)
|
||||
// "led_bezel_stl" LED status bezel (print 1×)
|
||||
//
|
||||
// Export commands (Issue #505 24V variant):
|
||||
// openscad charging_dock_505.scad -D 'RENDER="base_stl"' -o dock_505_base.stl
|
||||
// openscad charging_dock_505.scad -D 'RENDER="back_wall_stl"' -o dock_505_back_wall.stl
|
||||
// openscad charging_dock_505.scad -D 'RENDER="guide_rail_stl"' -o dock_505_guide_rail.stl
|
||||
// openscad charging_dock_505.scad -D 'RENDER="aruco_mount_stl"' -o dock_505_aruco_mount.stl
|
||||
// openscad charging_dock_505.scad -D 'RENDER="psu_bracket_stl"' -o dock_505_psu_bracket.stl
|
||||
// openscad charging_dock_505.scad -D 'RENDER="led_bezel_stl"' -o dock_505_led_bezel.stl
|
||||
// ============================================================
|
||||
|
||||
$fn = 64;
|
||||
e = 0.01;
|
||||
|
||||
// ── Base plate dimensions ─────────────────────────────────────────────────────
|
||||
// NOTE: Enlarged for 24V PSU (IRM-240-24: 210×108×56 mm vs. IRM-30-5: 63×45×28 mm)
|
||||
BASE_W = 340.0; // base width (X) — increased for larger PSU bracket
|
||||
BASE_D = 320.0; // base depth (Y, extends behind and in front of back wall)
|
||||
BASE_T = 12.0; // base thickness
|
||||
BASE_R = 10.0; // corner radius
|
||||
|
||||
// Ballast pockets (for steel hex bar / bolt weights):
|
||||
// 4× pockets in base underside, accept M20 hex nuts (30 mm AF) stacked
|
||||
BALLAST_N = 4;
|
||||
BALLAST_W = 32.0; // pocket width (hex nut AF + 2 mm)
|
||||
BALLAST_D = 32.0; // pocket depth
|
||||
BALLAST_T = 8.0; // pocket depth (≤ BASE_T/2)
|
||||
BALLAST_INSET_X = 50.0;
|
||||
BALLAST_INSET_Y = 40.0;
|
||||
|
||||
// Floor bolt holes (M8, for bolting dock to bench/floor — optional)
|
||||
FLOOR_BOLT_D = 8.5;
|
||||
FLOOR_BOLT_INSET_X = 30.0;
|
||||
FLOOR_BOLT_INSET_Y = 25.0;
|
||||
|
||||
// ── Back wall (upright panel) ─────────────────────────────────────────────────
|
||||
WALL_W = 250.0; // wall width (X) — same as guide entry span
|
||||
WALL_H = 85.0; // wall height (Z)
|
||||
WALL_T = 10.0; // wall thickness (Y)
|
||||
|
||||
// Back wall Y position relative to base rear edge
|
||||
// Wall sits at Y=0 (its front face); base extends behind it (-Y) and in front (+Y)
|
||||
BASE_REAR_Y = -80.0; // base rear edge Y coordinate
|
||||
|
||||
// ── Pogo pin housing (in back wall front face) ────────────────────────────────
|
||||
// High-current pogo pins: Ø5.5 mm body, 20 mm long (compressed), 4 mm spring travel
|
||||
// Rated 5 A each; 2× pins for +/- power
|
||||
POGO_D = 5.5; // pogo pin body OD
|
||||
POGO_BORE_D = 5.7; // bore diameter (0.2 mm clearance)
|
||||
POGO_L = 20.0; // pogo full length (uncompressed)
|
||||
POGO_TRAVEL = 4.0; // spring travel
|
||||
POGO_FLANGE_D = 8.0; // pogo flange / retention shoulder OD
|
||||
POGO_FLANGE_T = 1.5; // flange thickness
|
||||
POGO_SPACING = 20.0; // CL-to-CL spacing between + and - pins
|
||||
POGO_Z = 35.0; // pogo CL height above dock floor
|
||||
POGO_PROTRUDE = 8.0; // pogo tip protrusion beyond wall face (uncompressed)
|
||||
// Wiring channel behind pogo (runs down to base)
|
||||
WIRE_CH_W = 8.0;
|
||||
WIRE_CH_H = POGO_Z + 5;
|
||||
|
||||
// ── LED bezel (4 status LEDs in back wall, above pogo pins) ───────────────────
|
||||
// LED order (left to right): Searching | Aligned | Charging | Full
|
||||
// Colours (suggested): Red | Yellow | Blue | Green
|
||||
LED_D = 5.0; // 5 mm through-hole LED
|
||||
LED_BORE_D = 5.2; // bore diameter
|
||||
LED_BEZEL_W = 80.0; // bezel plate width
|
||||
LED_BEZEL_H = 18.0; // bezel plate height
|
||||
LED_BEZEL_T = 4.0; // bezel plate thickness
|
||||
LED_SPACING = 16.0; // LED centre-to-centre
|
||||
LED_Z = 65.0; // LED centre height above floor
|
||||
LED_INSET_D = 2.0; // LED recess depth (LED body recessed for protection)
|
||||
|
||||
// ── V-guide rails ─────────────────────────────────────────────────────────────
|
||||
// Robot receiver width (contact block): 30 mm.
|
||||
// Alignment tolerance: ±20 mm → entry gap = 30 + 2×20 = 70 mm.
|
||||
// Guide rail tapers from 70 mm entry (at Y = GUIDE_L) to 30 mm exit (at Y=0).
|
||||
// Each rail is a wedge-shaped wall.
|
||||
GUIDE_L = 100.0; // guide rail length (Y depth, from back wall)
|
||||
GUIDE_H = 50.0; // guide rail height (Z)
|
||||
GUIDE_T = 8.0; // guide rail wall thickness
|
||||
RECV_W = 30.0; // robot receiver contact block width
|
||||
ENTRY_GAP = 70.0; // guide entry gap (= RECV_W + 2×20 mm tolerance)
|
||||
EXIT_GAP = RECV_W + 2.0; // guide exit gap (2 mm clearance on each side)
|
||||
// Derived: half-gap at entry = 35 mm, at exit = 16 mm; taper = 19 mm over 100 mm
|
||||
// Half-angle = atan(19/100) ≈ 10.8° — gentle enough for reliable self-alignment
|
||||
|
||||
// ── ArUco marker mount ────────────────────────────────────────────────────────
|
||||
// Mounted at dock entry arch (forward of guide rails), tilted 15° back.
|
||||
// Robot camera acquires marker for coarse approach alignment.
|
||||
// ArUco marker ID 42 (DICT_4X4_250), 100×100 mm (printed/laminated on paper).
|
||||
ARUCO_MARKER_W = 100.0;
|
||||
ARUCO_MARKER_H = 100.0;
|
||||
ARUCO_FRAME_T = 3.0; // frame plate thickness
|
||||
ARUCO_FRAME_BDR = 10.0; // frame border around marker
|
||||
ARUCO_SLOT_T = 1.5; // marker slip-in slot depth
|
||||
ARUCO_MAST_H = 95.0; // mast height above base (centres marker at camera height)
|
||||
ARUCO_MAST_W = 10.0;
|
||||
ARUCO_TILT = 15.0; // backward tilt (degrees) — faces approaching robot
|
||||
ARUCO_Y = GUIDE_L + 60; // mast Y position (in front of guide entry)
|
||||
|
||||
// ── PSU bracket ───────────────────────────────────────────────────────────────
|
||||
// Mean Well IRM-240-24 (24V 10A 240W): 210×108×56 mm body — Issue #505 upgrade
|
||||
// Bracket sits behind back wall, on base plate.
|
||||
PSU_W = 220.0; // bracket internal width (+5 mm clearance per side for 210 mm PSU)
|
||||
PSU_D = 118.0; // bracket internal depth (+5 mm clearance per side for 108 mm PSU)
|
||||
PSU_H = 66.0; // bracket internal height (+5 mm top clearance for 56 mm PSU + ventilation)
|
||||
PSU_T = 4.0; // bracket wall thickness (thicker for larger PSU mass)
|
||||
PSU_Y = BASE_REAR_Y + PSU_D/2 + PSU_T + 10; // PSU Y centre
|
||||
|
||||
// ── Fasteners ─────────────────────────────────────────────────────────────────
|
||||
M3_D = 3.3;
|
||||
M4_D = 4.3;
|
||||
M5_D = 5.3;
|
||||
M8_D = 8.5;
|
||||
|
||||
// ============================================================
|
||||
// RENDER DISPATCH
|
||||
// ============================================================
|
||||
RENDER = "assembly";
|
||||
|
||||
if (RENDER == "assembly") assembly();
|
||||
else if (RENDER == "base_stl") dock_base();
|
||||
else if (RENDER == "back_wall_stl") back_wall();
|
||||
else if (RENDER == "guide_rail_stl") guide_rail("left");
|
||||
else if (RENDER == "aruco_mount_stl") aruco_mount();
|
||||
else if (RENDER == "psu_bracket_stl") psu_bracket();
|
||||
else if (RENDER == "led_bezel_stl") led_bezel();
|
||||
|
||||
// ============================================================
|
||||
// ASSEMBLY PREVIEW
|
||||
// ============================================================
|
||||
module assembly() {
|
||||
// Base plate
|
||||
color("SaddleBrown", 0.85) dock_base();
|
||||
|
||||
// Back wall
|
||||
color("Sienna", 0.85)
|
||||
translate([0, 0, BASE_T])
|
||||
back_wall();
|
||||
|
||||
// Left guide rail
|
||||
color("Peru", 0.85)
|
||||
translate([0, 0, BASE_T])
|
||||
guide_rail("left");
|
||||
|
||||
// Right guide rail (mirror in X)
|
||||
color("Peru", 0.85)
|
||||
translate([0, 0, BASE_T])
|
||||
mirror([1, 0, 0])
|
||||
guide_rail("left");
|
||||
|
||||
// ArUco mount
|
||||
color("DimGray", 0.85)
|
||||
translate([0, 0, BASE_T])
|
||||
aruco_mount();
|
||||
|
||||
// PSU bracket
|
||||
color("DarkSlateGray", 0.80)
|
||||
translate([0, PSU_Y, BASE_T])
|
||||
psu_bracket();
|
||||
|
||||
// LED bezel
|
||||
color("LightGray", 0.90)
|
||||
translate([0, -WALL_T/2, BASE_T + LED_Z])
|
||||
led_bezel();
|
||||
|
||||
// Ghost robot receiver approaching from +Y
|
||||
%color("SteelBlue", 0.25)
|
||||
translate([0, GUIDE_L + 30, BASE_T + POGO_Z])
|
||||
cube([RECV_W, 20, 8], center = true);
|
||||
|
||||
// Ghost pogo pins
|
||||
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
|
||||
%color("Gold", 0.60)
|
||||
translate([px, -POGO_PROTRUDE, BASE_T + POGO_Z])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d = POGO_D, h = POGO_L);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART A — DOCK BASE PLATE
|
||||
// ============================================================
|
||||
module dock_base() {
|
||||
difference() {
|
||||
// ── Main base block (rounded rect) ──────────────────────────
|
||||
linear_extrude(BASE_T)
|
||||
minkowski() {
|
||||
square([BASE_W - 2*BASE_R,
|
||||
BASE_D - 2*BASE_R], center = true);
|
||||
circle(r = BASE_R);
|
||||
}
|
||||
|
||||
// ── Ballast pockets (underside) ──────────────────────────────
|
||||
// 4× pockets: 2 front, 2 rear
|
||||
for (bx = [-1, 1])
|
||||
for (by = [-1, 1])
|
||||
translate([bx * (BASE_W/2 - BALLAST_INSET_X),
|
||||
by * (BASE_D/2 - BALLAST_INSET_Y),
|
||||
-e])
|
||||
cube([BALLAST_W, BALLAST_D, BALLAST_T + e], center = true);
|
||||
|
||||
// ── Floor bolt holes (M8, 4 corners) ────────────────────────
|
||||
for (bx = [-1, 1])
|
||||
for (by = [-1, 1])
|
||||
translate([bx * (BASE_W/2 - FLOOR_BOLT_INSET_X),
|
||||
by * (BASE_D/2 - FLOOR_BOLT_INSET_Y), -e])
|
||||
cylinder(d = FLOOR_BOLT_D, h = BASE_T + 2*e);
|
||||
|
||||
// ── Back wall attachment slots (M4, top face) ─────────────────
|
||||
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
|
||||
translate([bx, -BASE_D/4, BASE_T - 3])
|
||||
cylinder(d = M4_D, h = 4 + e);
|
||||
|
||||
// ── Guide rail attachment holes (M4) ──────────────────────────
|
||||
for (side = [-1, 1])
|
||||
for (gy = [20, GUIDE_L - 20])
|
||||
translate([side * (EXIT_GAP/2 + GUIDE_T/2), gy, BASE_T - 3])
|
||||
cylinder(d = M4_D, h = 4 + e);
|
||||
|
||||
// ── Cable routing slot (from pogo wires to PSU, through base) ─
|
||||
translate([0, -WALL_T - 5, -e])
|
||||
cube([WIRE_CH_W, 15, BASE_T + 2*e], center = true);
|
||||
|
||||
// ── Anti-skid texture (front face chamfer) ───────────────────
|
||||
// Chamfer front-bottom edge for easy robot approach
|
||||
translate([0, BASE_D/2 + e, -e])
|
||||
rotate([45, 0, 0])
|
||||
cube([BASE_W + 2*e, 5, 5], center = true);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART B — BACK WALL (upright panel)
|
||||
// ============================================================
|
||||
module back_wall() {
|
||||
difference() {
|
||||
union() {
|
||||
// ── Wall slab ────────────────────────────────────────────
|
||||
translate([-WALL_W/2, -WALL_T, 0])
|
||||
cube([WALL_W, WALL_T, WALL_H]);
|
||||
|
||||
// ── Pogo pin housing bosses (front face) ─────────────────
|
||||
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
|
||||
translate([px, -WALL_T, POGO_Z])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d = POGO_FLANGE_D + 6,
|
||||
h = POGO_PROTRUDE);
|
||||
|
||||
// ── Wiring channel reinforcement (inside wall face) ───────
|
||||
translate([-WIRE_CH_W/2 - 2, -WALL_T, 0])
|
||||
cube([WIRE_CH_W + 4, 4, WIRE_CH_H]);
|
||||
}
|
||||
|
||||
// ── Pogo pin bores (through wall into housing boss) ───────────
|
||||
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
|
||||
translate([px, POGO_PROTRUDE + e, POGO_Z])
|
||||
rotate([90, 0, 0]) {
|
||||
// Main bore (full depth through wall + boss)
|
||||
cylinder(d = POGO_BORE_D,
|
||||
h = WALL_T + POGO_PROTRUDE + 2*e);
|
||||
// Flange shoulder counterbore (retains pogo from pulling out)
|
||||
translate([0, 0, WALL_T + POGO_PROTRUDE - POGO_FLANGE_T - 1])
|
||||
cylinder(d = POGO_FLANGE_D + 0.4,
|
||||
h = POGO_FLANGE_T + 2);
|
||||
}
|
||||
|
||||
// ── Wiring channel (vertical slot, inside face → base cable hole) ─
|
||||
translate([-WIRE_CH_W/2, 0 + e, 0])
|
||||
cube([WIRE_CH_W, WALL_T/2, WIRE_CH_H]);
|
||||
|
||||
// ── LED bezel recess (in front face, above pogo) ──────────────
|
||||
translate([-LED_BEZEL_W/2, -LED_BEZEL_T, LED_Z - LED_BEZEL_H/2])
|
||||
cube([LED_BEZEL_W, LED_BEZEL_T + e, LED_BEZEL_H]);
|
||||
|
||||
// ── M4 base attachment bores (3 through bottom of wall) ───────
|
||||
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
|
||||
translate([bx, -WALL_T/2, -e])
|
||||
cylinder(d = M4_D, h = 8 + e);
|
||||
|
||||
// ── Cable tie slots (in wall body, for neat wire routing) ─────
|
||||
for (cz = [15, POGO_Z - 15])
|
||||
translate([WIRE_CH_W/2 + 3, -WALL_T/2, cz])
|
||||
cube([4, WALL_T + 2*e, 3], center = true);
|
||||
|
||||
// ── Lightening cutout (rear face pocket) ──────────────────────
|
||||
translate([-WALL_W/2 + 40, 0, 20])
|
||||
cube([WALL_W - 80, WALL_T/2 + e, WALL_H - 30]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART C — V-GUIDE RAIL
|
||||
// ============================================================
|
||||
// Print 2×; mirror in X for right side.
|
||||
// Rail tapers from ENTRY_GAP/2 (at Y=GUIDE_L) to EXIT_GAP/2 (at Y=0).
|
||||
// Inner (guiding) face is angled; outer face is vertical.
|
||||
module guide_rail(side = "left") {
|
||||
// Inner face X at back wall = EXIT_GAP/2
|
||||
// Inner face X at entry = ENTRY_GAP/2
|
||||
x_back = EXIT_GAP/2; // 16 mm
|
||||
x_entry = ENTRY_GAP/2; // 35 mm
|
||||
|
||||
difference() {
|
||||
union() {
|
||||
// ── Main wedge body ─────────────────────────────────────
|
||||
// Hull between two rectangles: narrow at Y=0, wide at Y=GUIDE_L
|
||||
hull() {
|
||||
// Back end (at Y=0, flush with back wall)
|
||||
translate([x_back, 0, 0])
|
||||
cube([GUIDE_T, e, GUIDE_H]);
|
||||
// Entry end (at Y=GUIDE_L)
|
||||
translate([x_entry, GUIDE_L, 0])
|
||||
cube([GUIDE_T, e, GUIDE_H]);
|
||||
}
|
||||
|
||||
// ── Entry flare (chamfered lip at guide entry for bump-entry) ─
|
||||
hull() {
|
||||
translate([x_entry, GUIDE_L, 0])
|
||||
cube([GUIDE_T, e, GUIDE_H]);
|
||||
translate([x_entry + 15, GUIDE_L + 20, 0])
|
||||
cube([GUIDE_T, e, GUIDE_H * 0.6]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M4 base attachment bores ─────────────────────────────────
|
||||
for (gy = [20, GUIDE_L - 20])
|
||||
translate([x_back + GUIDE_T/2, gy, -e])
|
||||
cylinder(d = M4_D, h = 8 + e);
|
||||
|
||||
// ── Chamfer on inner top corner (smooth robot entry) ─────────
|
||||
translate([x_back - e, -e, GUIDE_H - 5])
|
||||
rotate([0, -45, 0])
|
||||
cube([8, GUIDE_L + 30, 8]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART D — ArUco MARKER MOUNT
|
||||
// ============================================================
|
||||
// Free-standing mast at dock entry. Mounts to base plate.
|
||||
// Marker face tilted 15° toward approaching robot.
|
||||
// Accepts 100×100 mm printed/laminated paper marker in slot.
|
||||
module aruco_mount() {
|
||||
frame_w = ARUCO_MARKER_W + 2*ARUCO_FRAME_BDR;
|
||||
frame_h = ARUCO_MARKER_H + 2*ARUCO_FRAME_BDR;
|
||||
mast_y = ARUCO_Y;
|
||||
|
||||
union() {
|
||||
// ── Mast column ───────────────────────────────────────────────
|
||||
translate([-ARUCO_MAST_W/2, mast_y - ARUCO_MAST_W/2, 0])
|
||||
cube([ARUCO_MAST_W, ARUCO_MAST_W, ARUCO_MAST_H]);
|
||||
|
||||
// ── Marker frame (tilted back ARUCO_TILT°) ────────────────────
|
||||
translate([0, mast_y, ARUCO_MAST_H])
|
||||
rotate([-ARUCO_TILT, 0, 0]) {
|
||||
difference() {
|
||||
// Frame plate
|
||||
translate([-frame_w/2, -ARUCO_FRAME_T, -frame_h/2])
|
||||
cube([frame_w, ARUCO_FRAME_T, frame_h]);
|
||||
|
||||
// Marker window (cutout for marker visibility)
|
||||
translate([-ARUCO_MARKER_W/2, -ARUCO_FRAME_T - e,
|
||||
-ARUCO_MARKER_H/2])
|
||||
cube([ARUCO_MARKER_W,
|
||||
ARUCO_FRAME_T + 2*e,
|
||||
ARUCO_MARKER_H]);
|
||||
|
||||
// Marker slip-in slot (insert from side)
|
||||
translate([-frame_w/2 - e,
|
||||
-ARUCO_SLOT_T - 0.3,
|
||||
-ARUCO_MARKER_H/2])
|
||||
cube([frame_w + 2*e,
|
||||
ARUCO_SLOT_T + 0.3,
|
||||
ARUCO_MARKER_H]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mast base foot (M4 bolts to dock base) ────────────────────
|
||||
difference() {
|
||||
translate([-20, mast_y - 20, 0])
|
||||
cube([40, 40, 5]);
|
||||
for (fx = [-12, 12]) for (fy = [-12, 12])
|
||||
translate([fx, mast_y + fy, -e])
|
||||
cylinder(d = M4_D, h = 6 + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART E — PSU BRACKET
|
||||
// ============================================================
|
||||
// Open-top retention bracket for PSU module.
|
||||
// PSU slides in from top; 2× M3 straps or cable ties retain it.
|
||||
// Bracket bolts to base plate via 4× M4 screws.
|
||||
module psu_bracket() {
|
||||
difference() {
|
||||
union() {
|
||||
// ── Outer bracket box (open top) ─────────────────────────
|
||||
_box_open_top(PSU_W + 2*PSU_T,
|
||||
PSU_D + 2*PSU_T,
|
||||
PSU_H + PSU_T);
|
||||
|
||||
// ── Base flange ──────────────────────────────────────────
|
||||
translate([-(PSU_W/2 + PSU_T + 8),
|
||||
-(PSU_D/2 + PSU_T + 8), -PSU_T])
|
||||
cube([PSU_W + 2*PSU_T + 16,
|
||||
PSU_D + 2*PSU_T + 16, PSU_T]);
|
||||
}
|
||||
|
||||
// ── PSU cavity ───────────────────────────────────────────────
|
||||
translate([0, 0, PSU_T])
|
||||
cube([PSU_W, PSU_D, PSU_H + e], center = true);
|
||||
|
||||
// ── Ventilation slots (sides) ─────────────────────────────────
|
||||
for (a = [0, 90, 180, 270])
|
||||
rotate([0, 0, a])
|
||||
translate([0, (PSU_D/2 + PSU_T)/2, PSU_H/2 + PSU_T])
|
||||
for (sz = [-PSU_H/4, 0, PSU_H/4])
|
||||
translate([0, 0, sz])
|
||||
cube([PSU_W * 0.5, PSU_T + 2*e, 5],
|
||||
center = true);
|
||||
|
||||
// ── Cable exit slot (bottom) ──────────────────────────────────
|
||||
translate([0, 0, -e])
|
||||
cube([15, PSU_D + 2*PSU_T + 2*e, PSU_T + 2*e],
|
||||
center = true);
|
||||
|
||||
// ── Base flange M4 bolts ──────────────────────────────────────
|
||||
for (fx = [-1, 1]) for (fy = [-1, 1])
|
||||
translate([fx * (PSU_W/2 + PSU_T + 4),
|
||||
fy * (PSU_D/2 + PSU_T + 4),
|
||||
-PSU_T - e])
|
||||
cylinder(d = M4_D, h = PSU_T + 2*e);
|
||||
|
||||
// ── Cable tie slots ───────────────────────────────────────────
|
||||
for (sz = [PSU_H/3, 2*PSU_H/3])
|
||||
translate([0, 0, PSU_T + sz])
|
||||
cube([PSU_W + 2*PSU_T + 2*e, 4, 4], center = true);
|
||||
}
|
||||
}
|
||||
|
||||
module _box_open_top(w, d, h) {
|
||||
difference() {
|
||||
cube([w, d, h], center = true);
|
||||
translate([0, 0, PSU_T + e])
|
||||
cube([w - 2*PSU_T, d - 2*PSU_T, h], center = true);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART F — LED STATUS BEZEL
|
||||
// ============================================================
|
||||
// 4 × 5 mm LEDs in a row. Press-fits into recess in back wall.
|
||||
// LED labels (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);
|
||||
}
|
||||
}
|
||||
41
chassis/charging_dock_505_BOM.csv
Normal file
41
chassis/charging_dock_505_BOM.csv
Normal file
@ -0,0 +1,41 @@
|
||||
Item,Description,Specification,Quantity,Unit Cost,Total Cost,Source Notes
|
||||
E1,Power Supply,Mean Well IRM-240-24 / Hi-Link HLK-240M24 (24V 10A 240W),1,$50.00,$50.00,Digi-Key / Amazon
|
||||
E2,12 AWG Silicone Wire,Red + Black 600V rated 5m spool,1,$15.00,$15.00,McMaster-Carr / AliExpress
|
||||
E3,PG7 Cable Gland,M20 IP67 5-8mm cable,2,$3.00,$6.00,AliExpress / Heilind
|
||||
E4,Varistor (MOV),18-28V 1kA,1,$1.00,$1.00,Digi-Key
|
||||
E5,Fuse 25A,T25 Slow-blow 5x20mm,1,$0.50,$0.50,Digi-Key
|
||||
E6,Fuse Holder,5x20mm inline 20A rated,1,$2.00,$2.00,Amazon
|
||||
E7,Crimp Ring Terminals,M3 12 AWG tin-plated,8,$0.20,$1.60,Heilind / AliExpress
|
||||
E8,Strain Relief Sleeve,5mm ID silicone 1m,1,$5.00,$5.00,McMaster-Carr
|
||||
C1,Pogo Pin Assembly,"Spring-loaded Ø5.5mm 20mm 20A 4mm travel",2,$10.00,$20.00,Preci-Dip / Jst / AliExpress
|
||||
C2,Brass Contact Pad,Ø12x2mm H68 brass bare,2,$3.00,$6.00,OnlineMetals / Metals USA
|
||||
C3,Solder Lug M3,Copper ring tin-plated,4,$0.40,$1.60,Heilind / Amazon
|
||||
L1,5mm LED Red,2.0V 20mA diffuse,1,$0.30,$0.30,Digi-Key
|
||||
L2,5mm LED Yellow,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
|
||||
L3,5mm LED Blue,3.2V 20mA diffuse,1,$0.50,$0.50,Digi-Key
|
||||
L4,5mm LED Green,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
|
||||
R1-R4,Resistor 1kΩ 1/4W,Metal film 1% tolerance,4,$0.10,$0.40,Digi-Key
|
||||
J1,Pin Header 2.54mm,1x6 right-angle,1,$0.50,$0.50,Digi-Key
|
||||
S1,INA219 I2C Shunt Monitor,16-bit I2C 0x40 26V max (Optional),1,$5.00,$5.00,Adafruit / Digi-Key
|
||||
S2,SMD Resistor 0.1Ω,1206 1W (Optional current sense),1,$1.00,$1.00,Digi-Key
|
||||
M1,M20 Hex Nut,Steel DIN 934 ~86g,8,$0.80,$6.40,Grainger / Home Depot
|
||||
M2,M4x16 SHCS,Stainless A4 DIN 912,16,$0.30,$4.80,Grainger
|
||||
M3,M4x10 BHCS,Stainless A4 DIN 7380,8,$0.25,$2.00,Grainger
|
||||
M4,M4 Heat-Set Insert,Brass threaded,20,$0.15,$3.00,McMaster-Carr
|
||||
M5,M3x16 SHCS,Stainless,4,$0.20,$0.80,Grainger
|
||||
M6,M3 Hex Nut,DIN 934,4,$0.10,$0.40,Grainger
|
||||
M7,M8x40 BHCS,Zinc-plated floor anchor,4,$0.50,$2.00,Grainger
|
||||
M8,Rubber Foot,Ø20x5mm self-adhesive,4,$0.80,$3.20,Amazon
|
||||
A1,ArUco Marker Print,"100x100mm ID=42 DICT_4X4_250 glossy photo (qty 2)",2,$1.50,$3.00,Print locally / AliExpress
|
||||
A2,Lamination Pouch,A4 80µm,2,$0.40,$0.80,Amazon / Staples
|
||||
A3,Acrylic Cover Sheet,Clear 3mm 150x150mm,1,$3.00,$3.00,McMaster-Carr
|
||||
X1,Solder Wire,63/37 Sn/Pb lead-free 1m,1,$3.00,$3.00,Digi-Key
|
||||
X2,Flux Paste,No-clean 25mL,1,$4.00,$4.00,Digi-Key
|
||||
X3,Loctite 243,Thread-locker 10mL,1,$4.00,$4.00,Grainger
|
||||
X4,Epoxy Adhesive,Two-part 25mL,1,$6.00,$6.00,Home Depot
|
||||
P1,PETG Filament (3D Print),"Natural/White 1kg ±15% waste factor",2.5,$20.00,$50.00,Prusament / Overture
|
||||
,,,,,
|
||||
SUBTOTAL (Electrical + Hardware + Consumables),,,,,,$234.00,excludes 3D printing
|
||||
SUBTOTAL (With 3D filament @ $20/kg),,,,,,$284.00,all materials
|
||||
LABOR ESTIMATE (Assembly ~4-6 hrs),,,,,,$150-225,tecnico time
|
||||
TOTAL PROJECT COST (Material + Labor),,,,,,$434-509,per dock
|
||||
|
Can't render this file because it has a wrong number of fields in line 37.
|
332
chassis/charging_dock_receiver_505.scad
Normal file
332
chassis/charging_dock_receiver_505.scad
Normal file
@ -0,0 +1,332 @@
|
||||
// ============================================================
|
||||
// charging_dock_receiver_505.scad — Robot-Side Charging Receiver (24V)
|
||||
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
|
||||
// ============================================================
|
||||
//
|
||||
// Robot-side contact plate that mates with the 24V charging dock pogo pins.
|
||||
// Forked from Issue #159 receiver (contact geometry unchanged; 12 AWG wire bore).
|
||||
// Each robot variant has a different mounting interface; the contact
|
||||
// geometry is identical across all variants (same pogo pin spacing).
|
||||
//
|
||||
// Variants:
|
||||
// A — lab_receiver() SaltyLab — mounts to underside of stem base ring
|
||||
// B — rover_receiver() SaltyRover — mounts to chassis belly (M4 deck holes)
|
||||
// C — tank_receiver() SaltyTank — mounts to skid plate / hull floor
|
||||
//
|
||||
// Contact geometry (common across variants):
|
||||
// 2× brass contact pads, Ø12 mm × 2 mm (press-fit into PETG housing)
|
||||
// Pad spacing: 20 mm CL-to-CL (matches dock POGO_SPACING exactly)
|
||||
// Contact face Z height matches dock pogo pin Z when robot is level
|
||||
// Polarity: marked + on top pin (conventional: positive = right when
|
||||
// facing dock; negative = left) — must match dock wiring.
|
||||
//
|
||||
// Approach guide nose:
|
||||
// A chamfered V-nose on the forward face guides the receiver block
|
||||
// into the dock's V-funnel. Taper half-angle ≈ 14° matches guide rails.
|
||||
// Nose width = RECV_W = 30 mm (matches dock EXIT_GAP - 2 mm clearance).
|
||||
//
|
||||
// Coordinate convention:
|
||||
// Z = 0 at receiver mounting face (against robot chassis/deck underside).
|
||||
// +Z points downward (toward dock floor).
|
||||
// Contact pads face +Y (toward dock back wall when docked).
|
||||
// Receiver centred on X = 0 (robot centreline).
|
||||
//
|
||||
// RENDER options:
|
||||
// "assembly" all 3 receivers side by side
|
||||
// "lab_stl" SaltyLab receiver (print 1×)
|
||||
// "rover_stl" SaltyRover receiver (print 1×)
|
||||
// "tank_stl" SaltyTank receiver (print 1×)
|
||||
// "contact_pad_2d" DXF — Ø12 mm brass pad profile (order from metal shop)
|
||||
//
|
||||
// Export (Issue #505 24V variant):
|
||||
// openscad charging_dock_receiver_505.scad -D 'RENDER="lab_stl"' -o receiver_505_lab.stl
|
||||
// openscad charging_dock_receiver_505.scad -D 'RENDER="rover_stl"' -o receiver_505_rover.stl
|
||||
// openscad charging_dock_receiver_505.scad -D 'RENDER="tank_stl"' -o receiver_505_tank.stl
|
||||
// openscad charging_dock_receiver_505.scad -D 'RENDER="contact_pad_2d"' -o contact_pad_505.dxf
|
||||
// ============================================================
|
||||
|
||||
$fn = 64;
|
||||
e = 0.01;
|
||||
|
||||
// ── Contact geometry (must match charging_dock.scad) ─────────────────────────
|
||||
POGO_SPACING = 20.0; // CL-to-CL (dock POGO_SPACING)
|
||||
PAD_D = 12.0; // contact pad OD (brass disc)
|
||||
PAD_T = 2.0; // contact pad thickness
|
||||
PAD_RECESS = 1.8; // pad pressed into housing (0.2 mm proud for contact)
|
||||
PAD_PROUD = 0.2; // pad face protrudes from housing face
|
||||
|
||||
// ── Common receiver body geometry ────────────────────────────────────────────
|
||||
RECV_W = 30.0; // receiver body width (X) — matches dock EXIT_GAP inner
|
||||
RECV_D = 25.0; // receiver body depth (Y, docking direction)
|
||||
RECV_H = 12.0; // receiver body height (Z, from mount face down)
|
||||
RECV_R = 3.0; // corner radius
|
||||
// V-nose geometry (front Y face — faces dock back wall)
|
||||
NOSE_CHAMFER = 10.0; // chamfer depth on X corners of front face
|
||||
|
||||
// Polarity indicator slot (on top/mount face: + on right, - on left)
|
||||
POL_SLOT_W = 4.0;
|
||||
POL_SLOT_D = 8.0;
|
||||
POL_SLOT_H = 1.0;
|
||||
|
||||
// Fasteners
|
||||
M2_D = 2.4;
|
||||
M3_D = 3.3;
|
||||
M4_D = 4.3;
|
||||
|
||||
// ── Mounting patterns ─────────────────────────────────────────────────────────
|
||||
// SaltyLab stem base ring (Ø25 mm stem, 4× M3 in ring at Ø40 mm BC)
|
||||
LAB_BC_D = 40.0;
|
||||
LAB_BOLT_D = M3_D;
|
||||
LAB_COLLAR_H = 15.0; // collar height above receiver body
|
||||
|
||||
// SaltyRover deck (M4 grid pattern, 30.5×30.5 mm matching FC pattern on deck)
|
||||
// Receiver uses 4× M4 holes at ±20 mm from centre (clear of deck electronics)
|
||||
ROVER_BOLT_SPC = 40.0;
|
||||
|
||||
// SaltyTank skid plate (M4 holes matching skid plate bolt pattern)
|
||||
// Uses 4× M4 at ±20 mm X, ±10 mm Y (inset from skid plate M4 positions)
|
||||
TANK_BOLT_SPC_X = 40.0;
|
||||
TANK_BOLT_SPC_Y = 20.0;
|
||||
TANK_NOSE_L = 20.0; // extended nose for tank (wider hull)
|
||||
|
||||
// ============================================================
|
||||
// RENDER DISPATCH
|
||||
// ============================================================
|
||||
RENDER = "assembly";
|
||||
|
||||
if (RENDER == "assembly") assembly();
|
||||
else if (RENDER == "lab_stl") lab_receiver();
|
||||
else if (RENDER == "rover_stl") rover_receiver();
|
||||
else if (RENDER == "tank_stl") tank_receiver();
|
||||
else if (RENDER == "contact_pad_2d") {
|
||||
projection(cut = true) translate([0, 0, -0.5])
|
||||
linear_extrude(1) circle(d = PAD_D);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ASSEMBLY PREVIEW
|
||||
// ============================================================
|
||||
module assembly() {
|
||||
// SaltyLab receiver
|
||||
color("RoyalBlue", 0.85)
|
||||
translate([-80, 0, 0])
|
||||
lab_receiver();
|
||||
|
||||
// SaltyRover receiver
|
||||
color("OliveDrab", 0.85)
|
||||
translate([0, 0, 0])
|
||||
rover_receiver();
|
||||
|
||||
// SaltyTank receiver
|
||||
color("SaddleBrown", 0.85)
|
||||
translate([80, 0, 0])
|
||||
tank_receiver();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COMMON RECEIVER BODY
|
||||
// ============================================================
|
||||
// Internal helper: the shared contact housing + V-nose.
|
||||
// Orientation: mount face = +Z top; contact face = +Y front.
|
||||
// All variant-specific modules call this, then add their mount interface.
|
||||
module _receiver_body() {
|
||||
difference() {
|
||||
union() {
|
||||
// ── Main housing block (rounded) ─────────────────────────
|
||||
linear_extrude(RECV_H)
|
||||
_recv_profile_2d();
|
||||
|
||||
// ── V-nose chamfer reinforcement ribs ─────────────────────
|
||||
// Two diagonal ribs at 45° reinforce the chamfered corners
|
||||
for (sx = [-1, 1])
|
||||
hull() {
|
||||
translate([sx*(RECV_W/2 - NOSE_CHAMFER),
|
||||
RECV_D/2, 0])
|
||||
cylinder(d = 3, h = RECV_H * 0.6);
|
||||
translate([sx*(RECV_W/2), RECV_D/2 - NOSE_CHAMFER, 0])
|
||||
cylinder(d = 3, h = RECV_H * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contact pad bores (2× Ø12 mm, press-fit) ─────────────────
|
||||
// Pads face +Y; bores from Y face into housing
|
||||
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
|
||||
translate([px, RECV_D/2 + e, RECV_H/2])
|
||||
rotate([90, 0, 0]) {
|
||||
// Pad press-fit bore
|
||||
cylinder(d = PAD_D + 0.1,
|
||||
h = PAD_RECESS + e);
|
||||
// Wire bore (behind pad, to mount face)
|
||||
translate([0, 0, PAD_RECESS])
|
||||
cylinder(d = 3.0,
|
||||
h = RECV_D + 2*e);
|
||||
}
|
||||
|
||||
// ── Polarity indicator slots on top face ──────────────────────
|
||||
// "+" slot: right pad (+X side)
|
||||
translate([POGO_SPACING/2, 0, -e])
|
||||
cube([POL_SLOT_W, POL_SLOT_D, POL_SLOT_H + e], center = true);
|
||||
// "-" indent: left pad (no slot = negative)
|
||||
|
||||
// ── Wire routing channel (on mount face / underside) ──────────
|
||||
// Trough connecting both pad bores for neat wire run
|
||||
translate([0, RECV_D/2 - POGO_SPACING/2, RECV_H - 3])
|
||||
cube([POGO_SPACING + 6, POGO_SPACING, 4], center = true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2D profile of receiver body with chamfered V-nose ────────────────────────
|
||||
module _recv_profile_2d() {
|
||||
hull() {
|
||||
// Rear corners (full width)
|
||||
for (sx = [-1, 1])
|
||||
translate([sx*(RECV_W/2 - RECV_R), -RECV_D/2 + RECV_R])
|
||||
circle(r = RECV_R);
|
||||
// Front corners (chamfered — narrowed by NOSE_CHAMFER)
|
||||
for (sx = [-1, 1])
|
||||
translate([sx*(RECV_W/2 - NOSE_CHAMFER - RECV_R),
|
||||
RECV_D/2 - RECV_R])
|
||||
circle(r = RECV_R);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART A — SALTYLAB RECEIVER
|
||||
// ============================================================
|
||||
// Mounts to the underside of the SaltyLab chassis stem base ring.
|
||||
// Split collar grips Ø25 mm stem; receiver body hangs below collar.
|
||||
// Z height set so contact pads align with dock pogo pins when robot
|
||||
// rests on flat surface (robot wheel-to-contact-pad height calibrated).
|
||||
//
|
||||
// Receiver height above floor: tune LAB_CONTACT_Z in firmware (UWB/ArUco
|
||||
// approach). Mechanically: receiver sits ~35 mm above ground (stem base
|
||||
// height), matching dock POGO_Z = 35 mm.
|
||||
module lab_receiver() {
|
||||
collar_od = 46.0; // matches sensor_rail.scad STEM_COL_OD
|
||||
collar_h = LAB_COLLAR_H;
|
||||
|
||||
union() {
|
||||
// ── Common receiver body ────────────────────────────────────
|
||||
_receiver_body();
|
||||
|
||||
// ── Stem collar (split, 2 halves joined with M4 bolts) ───────
|
||||
// Only the front half printed here; rear half is mirror.
|
||||
translate([0, -RECV_D/2, RECV_H])
|
||||
difference() {
|
||||
// Half-collar cylinder
|
||||
rotate_extrude(angle = 180)
|
||||
translate([collar_od/2 - 8, 0, 0])
|
||||
square([8, collar_h]);
|
||||
|
||||
// Stem bore clearance
|
||||
translate([0, 0, -e])
|
||||
cylinder(d = 25.5, h = collar_h + 2*e);
|
||||
|
||||
// 2× M4 clamping bolt bores (through collar flanges)
|
||||
for (cx = [-collar_od/2 + 4, collar_od/2 - 4])
|
||||
translate([cx, 0, collar_h/2])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d = M4_D,
|
||||
h = collar_od + 2*e,
|
||||
center = true);
|
||||
}
|
||||
|
||||
// ── M3 receiver-to-collar bolts ───────────────────────────────
|
||||
// 4× M3 holes connecting collar flange to receiver body top
|
||||
// (These are mounting holes for assembly; not holes in the part)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART B — SALTYOVER RECEIVER
|
||||
// ============================================================
|
||||
// Mounts to the underside of the SaltyRover deck plate.
|
||||
// 4× M4 bolts into deck underside (blind holes tapped in deck).
|
||||
// Receiver sits flush with deck belly; contact pads protrude 5 mm below.
|
||||
// Dock pogo Z = 35 mm must equal ground-to-deck-belly height for rover
|
||||
// (approximately 60 mm chassis clearance — shim with spacer if needed).
|
||||
module rover_receiver() {
|
||||
mount_h = 5.0; // mounting flange thickness
|
||||
|
||||
union() {
|
||||
// ── Common receiver body ────────────────────────────────────
|
||||
_receiver_body();
|
||||
|
||||
// ── Mounting flange (attaches to deck belly) ─────────────────
|
||||
difference() {
|
||||
translate([-(ROVER_BOLT_SPC/2 + 12),
|
||||
-RECV_D/2 - 10,
|
||||
RECV_H])
|
||||
cube([ROVER_BOLT_SPC + 24,
|
||||
RECV_D + 20,
|
||||
mount_h]);
|
||||
|
||||
// 4× M4 bolt holes
|
||||
for (fx = [-1, 1]) for (fy = [-1, 1])
|
||||
translate([fx*ROVER_BOLT_SPC/2,
|
||||
fy*(RECV_D/2 + 5),
|
||||
RECV_H - e])
|
||||
cylinder(d = M4_D,
|
||||
h = mount_h + 2*e);
|
||||
|
||||
// Weight-reduction pockets
|
||||
for (sx = [-1, 1])
|
||||
translate([sx*(ROVER_BOLT_SPC/4 + 6),
|
||||
0, RECV_H + 1])
|
||||
cube([ROVER_BOLT_SPC/2 - 4, RECV_D - 4, mount_h],
|
||||
center = true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART C — SALTYTANK RECEIVER
|
||||
// ============================================================
|
||||
// Mounts to SaltyTank hull floor or replaces a section of skid plate.
|
||||
// Extended front nose (TANK_NOSE_L) for tank's wider hull approach.
|
||||
// Contact pads exposed through skid plate via a 30×16 mm slot.
|
||||
// Ground clearance: tank chassis = 90 mm; dock POGO_Z = 35 mm.
|
||||
// Use ramp shim (see BOM) under dock base to elevate pogo pins to 90 mm
|
||||
// OR set POGO_Z = 90 in dock for a tank-specific dock configuration.
|
||||
// ⚠ Cross-variant dock: set POGO_Z per robot if heights differ.
|
||||
// Compromise: POGO_Z = 60 mm with 25 mm ramp for tank, 25 mm spacer for lab.
|
||||
module tank_receiver() {
|
||||
mount_h = 4.0;
|
||||
nose_l = RECV_D/2 + TANK_NOSE_L;
|
||||
|
||||
union() {
|
||||
// ── Common receiver body ────────────────────────────────────
|
||||
_receiver_body();
|
||||
|
||||
// ── Extended nose for tank approach ──────────────────────────
|
||||
// Additional chamfered wedge ahead of standard receiver body
|
||||
hull() {
|
||||
// Receiver front face corners
|
||||
for (sx = [-1, 1])
|
||||
translate([sx*(RECV_W/2 - NOSE_CHAMFER), RECV_D/2, 0])
|
||||
cylinder(d = 2*RECV_R, h = RECV_H * 0.5);
|
||||
// Extended nose tip (narrowed to 20 mm)
|
||||
for (sx = [-1, 1])
|
||||
translate([sx*10, RECV_D/2 + TANK_NOSE_L, 0])
|
||||
cylinder(d = 2*RECV_R, h = RECV_H * 0.4);
|
||||
}
|
||||
|
||||
// ── Mounting flange (bolts to tank skid plate) ────────────────
|
||||
difference() {
|
||||
translate([-(TANK_BOLT_SPC_X/2 + 10),
|
||||
-RECV_D/2 - 8,
|
||||
RECV_H])
|
||||
cube([TANK_BOLT_SPC_X + 20,
|
||||
RECV_D + 16,
|
||||
mount_h]);
|
||||
|
||||
// 4× M4 bolt holes
|
||||
for (fx = [-1, 1]) for (fy = [-1, 1])
|
||||
translate([fx*TANK_BOLT_SPC_X/2,
|
||||
fy*TANK_BOLT_SPC_Y/2,
|
||||
RECV_H - e])
|
||||
cylinder(d = M4_D,
|
||||
h = mount_h + 2*e);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
jetson/ros2_ws/src/saltybot_audio_pipeline/README.md
Normal file
39
jetson/ros2_ws/src/saltybot_audio_pipeline/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Audio Pipeline (Issue #503)
|
||||
|
||||
Comprehensive audio pipeline for Salty Bot with full voice interaction support.
|
||||
|
||||
## Features
|
||||
|
||||
- **Hardware**: Jabra SPEAK 810 USB audio device integration
|
||||
- **Wake Word**: openwakeword "Hey Salty" detection
|
||||
- **STT**: whisper.cpp running on Jetson GPU (small/base/medium/large models)
|
||||
- **TTS**: Piper synthesis with voice switching
|
||||
- **State Machine**: listening → processing → speaking
|
||||
- **MQTT**: Real-time status reporting
|
||||
- **Metrics**: Latency tracking and performance monitoring
|
||||
|
||||
## ROS2 Topics
|
||||
|
||||
Published:
|
||||
- `/saltybot/speech/transcribed_text` (String): Final STT output
|
||||
- `/saltybot/audio/state` (String): Current audio state
|
||||
- `/saltybot/audio/status` (String): JSON metrics with latencies
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
- `saltybot/audio/state`: Current state
|
||||
- `saltybot/audio/status`: Complete status JSON
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
ros2 launch saltybot_audio_pipeline audio_pipeline.launch.py
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See `config/audio_pipeline_params.yaml` for tuning:
|
||||
- `device_name`: Jabra device
|
||||
- `wake_word_threshold`: 0.5 (0.0-1.0)
|
||||
- `whisper_model`: small/base/medium/large
|
||||
- `mqtt_enabled`: true/false
|
||||
@ -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"
|
||||
@ -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,
|
||||
),
|
||||
])
|
||||
12
jetson/ros2_ws/src/saltybot_audio_pipeline/package.xml
Normal file
12
jetson/ros2_ws/src/saltybot_audio_pipeline/package.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0"?>
|
||||
<package format="3">
|
||||
<name>saltybot_audio_pipeline</name>
|
||||
<version>1.0.0</version>
|
||||
<description>Full audio pipeline: Jabra SPEAK 810, wake word, STT, TTS with MQTT (Issue #503)</description>
|
||||
<maintainer email="seb@saltylab.ai">Salty Lab</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
<buildtool_depend>ament_python</buildtool_depend>
|
||||
<depend>rclpy</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<test_depend>pytest</test_depend>
|
||||
</package>
|
||||
@ -0,0 +1,2 @@
|
||||
"""Audio pipeline for Salty Bot."""
|
||||
__version__ = "1.0.0"
|
||||
@ -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()
|
||||
@ -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)
|
||||
2
jetson/ros2_ws/src/saltybot_audio_pipeline/setup.cfg
Normal file
2
jetson/ros2_ws/src/saltybot_audio_pipeline/setup.cfg
Normal file
@ -0,0 +1,2 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/saltybot_audio_pipeline/scripts
|
||||
21
jetson/ros2_ws/src/saltybot_audio_pipeline/setup.py
Normal file
21
jetson/ros2_ws/src/saltybot_audio_pipeline/setup.py
Normal file
@ -0,0 +1,21 @@
|
||||
from setuptools import setup
|
||||
package_name = 'saltybot_audio_pipeline'
|
||||
setup(
|
||||
name=package_name,
|
||||
version='1.0.0',
|
||||
packages=[package_name],
|
||||
data_files=[
|
||||
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
|
||||
('share/' + package_name, ['package.xml']),
|
||||
('share/' + package_name + '/launch', ['launch/audio_pipeline.launch.py']),
|
||||
('share/' + package_name + '/config', ['config/audio_pipeline_params.yaml']),
|
||||
],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
author='Salty Lab',
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'audio_pipeline_node = saltybot_audio_pipeline.audio_pipeline_node:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
@ -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 <jetson-ip>`
|
||||
### "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 <jetson-ip>
|
||||
```
|
||||
|
||||
### 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 <jetson-ip>
|
||||
nc -zv <jetson-ip> 9090 # For WebSocket mode
|
||||
```
|
||||
|
||||
4. **Check phone ROS_DOMAIN_ID matches Jetson** (if using ROS2):
|
||||
```bash
|
||||
# Should match: export ROS_DOMAIN_ID=0 (default)
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
# Remove script
|
||||
rm ~/saltylab-firmware/phone/motor_test_joystick.py
|
||||
|
||||
# Remove auto-launch
|
||||
rm ~/.termux/boot/start_motor_test.sh
|
||||
|
||||
# Stop running process (if active)
|
||||
pkill -f motor_test_joystick
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues, refer to:
|
||||
- Main documentation: `MOTOR_TEST_JOYSTICK.md`
|
||||
- Issue tracker: https://gitea.vayrette.com/seb/saltylab-firmware/issues/513
|
||||
- Termux wiki: https://wiki.termux.com/
|
||||
|
||||
@ -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 <jetson-ip> # 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 <jetson-ip>
|
||||
```
|
||||
|
||||
## Controls
|
||||
@ -17,47 +21,157 @@ python3 motor_test_joystick.py --backend websocket --host <jetson-ip> # 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 <ip>`, verify bridge listening on :9090
|
||||
|
||||
### Motors not responding
|
||||
1. Check e-stop status (should show "✓ Inactive")
|
||||
2. Verify timeout warning (>500ms = zero velocity sent)
|
||||
3. Check Jetson `/cmd_vel` subscription: `ros2 topic echo /cmd_vel`
|
||||
4. Verify network connectivity (WiFi/tethering)
|
||||
|
||||
### Terminal artifacts / display issues
|
||||
- Try `reset` or `stty sane` in Termux
|
||||
- Increase terminal size (pinch-zoom)
|
||||
- Use `--backend websocket` (simpler UI fallback)
|
||||
|
||||
## Safety Checklist Before Testing
|
||||
|
||||
- [ ] Phone connected to Jetson (WiFi or USB tether)
|
||||
- [ ] Motors disconnected or isolated (bench testing mode)
|
||||
- [ ] E-stop accessible (spacebar, always reachable)
|
||||
- [ ] Terminal window visible (no hide/scroll)
|
||||
- [ ] Max velocities appropriate (start conservative: 0.1/0.3)
|
||||
- [ ] Kill switch ready (Ctrl+C, or `ros2 topic pub --once /cmd_vel ...`)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Gamepad/joystick input (evdev) instead of keyboard
|
||||
- [ ] Configurable button mappings
|
||||
- [ ] Velocity profile presets (slow/medium/fast)
|
||||
- [ ] Motor current feedback from motor driver
|
||||
- [ ] Telemetry logging (CSV) for bench analysis
|
||||
- [ ] Multi-motor independent control
|
||||
|
||||
## Related Issues
|
||||
|
||||
- #420 — Termux bootstrap & Android phone node
|
||||
- #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)
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user