Compare commits
2 Commits
9c517c468f
...
5108fa8fa1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5108fa8fa1 | |||
| 5362536fb1 |
@ -1,208 +0,0 @@
|
|||||||
# Issue #194 — Speed Limiter Node Implementation Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The SaltyBot control stack currently lacks a dedicated speed limiting aggregator. Individual subsystems publish speed scale factors:
|
|
||||||
- **TerrainAdaptationNode** → `/saltybot/terrain_speed_scale` (Float32, 0.15–1.0)
|
|
||||||
- **EmergencyNode** → `/saltybot/emergency` (EmergencyEvent with severity level)
|
|
||||||
- Hypothetical external source → `/saltybot/speed_limit` (to be determined)
|
|
||||||
|
|
||||||
These must be unified into a single authoritative speed scale and applied to `/cmd_vel` before forwarding to the drive system. Currently, each subsystem is independent, creating potential conflicts and inconsistent behavior.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Create `saltybot_speed_limiter` ROS2 package that:
|
|
||||||
1. Subscribes to terrain_speed_scale, speed_limit, and emergency signals
|
|
||||||
2. Computes effective minimum speed scale from all three inputs
|
|
||||||
3. Applies scale to /cmd_vel messages → /saltybot/cmd_vel_limited
|
|
||||||
4. Publishes diagnostic JSON on /saltybot/speed_limit_info
|
|
||||||
5. Operates at 50 Hz with proper message synchronization
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Subscriptions
|
|
||||||
|
|
||||||
| Topic | Type | Source | Frequency | Use |
|
|
||||||
|-------|------|--------|-----------|-----|
|
|
||||||
| `/saltybot/terrain_speed_scale` | Float32 | TerrainAdaptationNode | 50Hz | Terrain-based scaling (0.15–1.0) |
|
|
||||||
| `/saltybot/speed_limit` | Float32 | External (TBD) | ≥10Hz | Global speed limit (0.0–1.0) |
|
|
||||||
| `/saltybot/emergency` | EmergencyEvent | EmergencyNode | 20Hz | Emergency severity level |
|
|
||||||
| `/cmd_vel` | Twist | SpeedControllerNode | ≥20Hz | Raw commanded velocity |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Topic | Type | Frequency | Content |
|
|
||||||
|-------|------|-----------|---------|
|
|
||||||
| `/saltybot/cmd_vel_limited` | Twist | 50Hz | Scaled cmd_vel |
|
|
||||||
| `/saltybot/speed_limit_info` | String (JSON) | 50Hz | Diagnostic state |
|
|
||||||
|
|
||||||
### Message Types
|
|
||||||
|
|
||||||
**EmergencyEvent** (from saltybot_emergency_msgs):
|
|
||||||
```
|
|
||||||
string severity # "CLEAR"|"MINOR"|"MAJOR"|"CRITICAL"
|
|
||||||
```
|
|
||||||
|
|
||||||
**speed_limit_info JSON**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"terrain_scale": 0.638,
|
|
||||||
"speed_limit": 1.0,
|
|
||||||
"emergency_scale": 1.0,
|
|
||||||
"effective_scale": 0.638,
|
|
||||||
"cmd_vel_input": {"linear": 1.0, "angular": 0.5},
|
|
||||||
"cmd_vel_limited": {"linear": 0.638, "angular": 0.319},
|
|
||||||
"timestamp": 1740000000.123456
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Control Logic
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Emergency severity → speed scale mapping
|
|
||||||
emergency_scale = {
|
|
||||||
"CLEAR": 1.0,
|
|
||||||
"MINOR": 0.95, # Yellow alert: slight caution
|
|
||||||
"MAJOR": 0.7, # Orange alert: significant slowdown
|
|
||||||
"CRITICAL": 0.3, # Red alert: severe limitation
|
|
||||||
}
|
|
||||||
|
|
||||||
# Effective scale = minimum of all sources
|
|
||||||
effective_scale = min(
|
|
||||||
terrain_scale, # From /saltybot/terrain_speed_scale
|
|
||||||
speed_limit, # From /saltybot/speed_limit
|
|
||||||
emergency_scale, # From /saltybot/emergency severity
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply to all cmd_vel components
|
|
||||||
cmd_vel_limited.linear.x *= effective_scale
|
|
||||||
cmd_vel_limited.linear.y *= effective_scale
|
|
||||||
cmd_vel_limited.linear.z *= effective_scale
|
|
||||||
cmd_vel_limited.angular.x *= effective_scale
|
|
||||||
cmd_vel_limited.angular.y *= effective_scale
|
|
||||||
cmd_vel_limited.angular.z *= effective_scale
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Package Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
saltybot_speed_limiter/
|
|
||||||
├── CMakeLists.txt
|
|
||||||
├── package.xml
|
|
||||||
├── setup.py
|
|
||||||
├── README.md
|
|
||||||
├── launch/
|
|
||||||
│ └── speed_limiter.launch.py
|
|
||||||
├── resource/
|
|
||||||
│ └── saltybot_speed_limiter
|
|
||||||
├── saltybot_speed_limiter/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── speed_limiter_node.py
|
|
||||||
├── config/
|
|
||||||
│ └── speed_limiter_params.yaml
|
|
||||||
└── test/
|
|
||||||
├── __init__.py
|
|
||||||
└── test_speed_limiter.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Implementation Points
|
|
||||||
|
|
||||||
1. **Message Synchronization**: Use `message_filters.ApproximateTimeSynchronizer` to align terrain_scale, speed_limit, emergency, and cmd_vel with small slop tolerance (50–100ms @ 50Hz)
|
|
||||||
|
|
||||||
2. **State Management**:
|
|
||||||
- Store latest values for each input (timeout handling if stale)
|
|
||||||
- Fallback to safe defaults if messages stop arriving:
|
|
||||||
- terrain_scale → 1.0 (no scaling)
|
|
||||||
- speed_limit → 1.0 (no limit)
|
|
||||||
- emergency → "CLEAR" (no threat)
|
|
||||||
|
|
||||||
3. **Parameters**:
|
|
||||||
- `publish_hz`: 50.0 (fixed, not tunable)
|
|
||||||
- `sync_slop_ms`: 100 (ApproximateTimeSynchronizer slop)
|
|
||||||
- `timeout_s`: 2.0 (signal timeout)
|
|
||||||
- `frame_id`: 'base_link'
|
|
||||||
- Emergency scale map (tunable per severity)
|
|
||||||
|
|
||||||
4. **Performance**:
|
|
||||||
- Small package, minimal computation (3 float comparisons + geometry scaling)
|
|
||||||
- No heavy libraries (just numpy for Twist scaling)
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
Test file: `test/test_speed_limiter.py`
|
|
||||||
|
|
||||||
Coverage:
|
|
||||||
- Min-of-three logic (all combinations)
|
|
||||||
- Twist scaling on all 6 components
|
|
||||||
- Emergency severity mapping (CLEAR, MINOR, MAJOR, CRITICAL)
|
|
||||||
- Timeout/stale handling (graceful fallback)
|
|
||||||
- JSON output structure and values
|
|
||||||
- Message synchronization edge cases
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
|
|
||||||
**Upstream** (inputs):
|
|
||||||
- SpeedControllerNode → `/cmd_vel`
|
|
||||||
- TerrainAdaptationNode → `/saltybot/terrain_speed_scale`
|
|
||||||
- EmergencyNode → `/saltybot/emergency`
|
|
||||||
|
|
||||||
**Downstream** (outputs):
|
|
||||||
- CmdVelBridgeNode ← `/saltybot/cmd_vel_limited` (replaces `/cmd_vel`)
|
|
||||||
|
|
||||||
**Migration Path**:
|
|
||||||
1. CmdVelBridgeNode currently reads `/cmd_vel`
|
|
||||||
2. After speed_limiter is ready: modify CmdVelBridgeNode to read `/saltybot/cmd_vel_limited` instead
|
|
||||||
3. Or: use ROS2 topic remapping in launch files during transition
|
|
||||||
|
|
||||||
## Files to Create
|
|
||||||
|
|
||||||
1. **saltybot_speed_limiter/speed_limiter_node.py** (~200 lines)
|
|
||||||
- SpeedLimiterNode class
|
|
||||||
- Message synchronization with fallback logic
|
|
||||||
- Scale computation and Twist application
|
|
||||||
- JSON diagnostic publishing
|
|
||||||
|
|
||||||
2. **test/test_speed_limiter.py** (~150 lines)
|
|
||||||
- Min-of-three logic tests
|
|
||||||
- Twist scaling verification
|
|
||||||
- Emergency map tests
|
|
||||||
- Timeout/stale handling tests
|
|
||||||
|
|
||||||
3. **launch/speed_limiter.launch.py** (~40 lines)
|
|
||||||
- Configurable parameters
|
|
||||||
- QoS setup
|
|
||||||
|
|
||||||
4. **config/speed_limiter_params.yaml** (~20 lines)
|
|
||||||
- Emergency severity scales
|
|
||||||
- Timeout and sync parameters
|
|
||||||
|
|
||||||
5. **package.xml, setup.py, CMakeLists.txt, README.md, resource/** (~150 lines total)
|
|
||||||
- Standard ROS2 package boilerplate
|
|
||||||
|
|
||||||
## Verification Plan
|
|
||||||
|
|
||||||
1. **Unit Tests**: Run pytest on test_speed_limiter.py (expect 15+ tests, all pass)
|
|
||||||
2. **Package Build**: `colcon build --packages-select saltybot_speed_limiter`
|
|
||||||
3. **Node Launch**: `ros2 launch saltybot_speed_limiter speed_limiter.launch.py`
|
|
||||||
4. **Manual Testing** (hardware):
|
|
||||||
- Subscribe to /saltybot/cmd_vel_limited and verify scaling
|
|
||||||
- Inject different terrain_scale, speed_limit, emergency values
|
|
||||||
- Confirm effective_scale = min(all three)
|
|
||||||
- Verify JSON diagnostic content
|
|
||||||
|
|
||||||
## Branch & Commit Strategy
|
|
||||||
|
|
||||||
1. Branch: `sl-controls/issue-194-speed-limiter` from main
|
|
||||||
2. Commit: Single commit with all 10 files
|
|
||||||
3. PR: To main branch with detailed description
|
|
||||||
4. MQTT Report: Send status to max after merge
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
- **Min-of-three logic** (not weighted average): Ensures safety — most conservative limit wins
|
|
||||||
- **Separate topic output** (`/saltybot/cmd_vel_limited`): Preserves original /cmd_vel for debugging
|
|
||||||
- **Emergency severity levels** (CLEAR/MINOR/MAJOR/CRITICAL): Map to scales 1.0/0.95/0.7/0.3 (can tune)
|
|
||||||
- **ApproximateTimeSynchronizer**: Handles async inputs at different rates
|
|
||||||
- **JSON string output** (not typed message): Easy to parse in logging/MQTT system
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
# Plan: SaltyRover STM32 Firmware Variant
|
|
||||||
|
|
||||||
## Context
|
|
||||||
SaltyRover is the 4-wheel-drive variant of SaltyBot. It uses the same STM32F722 MCU, CRSF RC, USB CDC, safety systems, and IMU — but replaces the balance PID loop with direct 4-wheel independent motor commands. Two hoverboard ESCs are used: front axle on USART2 (same as balance bot), rear axle on UART5 (PC12/PD2).
|
|
||||||
|
|
||||||
saltyrover-dev is 16 commits behind main and lacks: mode_manager, remote e-stop (PR #69), jetson_cmd updates, and some CDC additions. We'll apply all relevant current main-equivalent features on the new branch.
|
|
||||||
|
|
||||||
## Branch
|
|
||||||
- Source: `origin/saltyrover-dev`
|
|
||||||
- Branch name: `sl-firmware/rover-firmware`
|
|
||||||
- PR target: `saltyrover-dev`
|
|
||||||
|
|
||||||
## Files to Create
|
|
||||||
|
|
||||||
### `include/rover_driver.h`
|
|
||||||
Public API for the 4-wheel rover motor layer:
|
|
||||||
```c
|
|
||||||
typedef struct {
|
|
||||||
int16_t fl, fr, rl, rr; /* last wheel commands (-1000..+1000) */
|
|
||||||
bool armed;
|
|
||||||
bool estop;
|
|
||||||
uint32_t last_cmd_ms; /* HAL_GetTick() of last set_cmd() call */
|
|
||||||
} rover_driver_t;
|
|
||||||
|
|
||||||
void rover_driver_init(rover_driver_t *r);
|
|
||||||
void rover_driver_set_cmd(rover_driver_t *r, int16_t fl, int16_t fr, int16_t rl, int16_t rr, uint32_t now);
|
|
||||||
void rover_driver_update(rover_driver_t *r, uint32_t now); /* call at 50Hz */
|
|
||||||
void rover_driver_arm(rover_driver_t *r);
|
|
||||||
void rover_driver_disarm(rover_driver_t *r);
|
|
||||||
void rover_driver_estop(rover_driver_t *r);
|
|
||||||
void rover_driver_estop_clear(rover_driver_t *r);
|
|
||||||
```
|
|
||||||
|
|
||||||
### `src/rover_driver.c`
|
|
||||||
Implementation — two hoverboard ESC control:
|
|
||||||
- `rover_driver_init()`: calls existing `hoverboard_init()` (USART2 front), adds UART5 init for rear (HAL_UART_Init at 115200, PC12/PD2)
|
|
||||||
- `rover_driver_update()` at 50Hz:
|
|
||||||
- estop or disarmed → send (0,0) to both ESCs
|
|
||||||
- armed → compute `front_spd=(fl+fr)/2`, `front_str=(fr-fl)/2`, `rear_spd=(rl+rr)/2`, `rear_str=(rr-rl)/2`; clamp each to ±ROVER_SPEED_MAX
|
|
||||||
- If `now - last_cmd_ms > ROVER_CMD_TIMEOUT_MS` → zero commands (safety fallback)
|
|
||||||
- Calls `hoverboard_send(front_spd, front_str)` for USART2
|
|
||||||
- Calls local `hoverboard2_send(rear_spd, rear_str)` for UART5 (static function in rover_driver.c, same 8-byte protocol)
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
### `include/config.h`
|
|
||||||
Add rover-specific constants section:
|
|
||||||
```c
|
|
||||||
/* --- SaltyRover 4WD --- */
|
|
||||||
#define ROVER_MAX_TILT_DEG 45.0f /* ESC estop angle (vs 25° balance cutoff) */
|
|
||||||
#define ROVER_CMD_TIMEOUT_MS 500 /* Zero wheels if no Jetson cmd for 500ms */
|
|
||||||
#define ROVER_SPEED_MAX 800 /* Max per-wheel command (ESC units) */
|
|
||||||
```
|
|
||||||
|
|
||||||
### `include/safety.h` + `src/safety.c`
|
|
||||||
Apply PR #69 remote-estop additions (same as main): `EstopSource` enum, `safety_remote_estop/clear/get/active()`, `s_estop_source` static. These are already on main but not saltyrover-dev — include them here.
|
|
||||||
|
|
||||||
### `lib/USB_CDC/src/usbd_cdc_if.c` + `include/usbd_cdc_if.h`
|
|
||||||
Apply PR #69 CDC additions: `cdc_estop_request`, `cdc_estop_clear_request`, cases 'E'/'F'/'Z'.
|
|
||||||
|
|
||||||
Add rover JSON command handling:
|
|
||||||
```c
|
|
||||||
volatile uint8_t rover_json_ready = 0;
|
|
||||||
volatile char rover_json_buf[80];
|
|
||||||
// In CDC_Receive:
|
|
||||||
case '{': {
|
|
||||||
uint32_t n = *len < 79 ? *len : 79;
|
|
||||||
for (uint32_t i = 0; i < n; i++) rover_json_buf[i] = (char)buf[i];
|
|
||||||
rover_json_buf[n] = '\0';
|
|
||||||
rover_json_ready = 1;
|
|
||||||
jetson_hb_tick = HAL_GetTick(); /* JSON cmd refreshes heartbeat */
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Export in header: `extern volatile uint8_t rover_json_ready; extern volatile char rover_json_buf[80];`
|
|
||||||
|
|
||||||
### `src/main.c`
|
|
||||||
Full replacement of the main loop. Keep all init (clock, USB, IMU, CRSF, I2C sensors, safety), strip `balance_t`, replace with `rover_driver_t`. Key loop logic:
|
|
||||||
|
|
||||||
**Per-iteration (1ms):**
|
|
||||||
1. `safety_refresh()` — IWDG feed
|
|
||||||
2. `mpu6000_read(&imu)` — IMU read
|
|
||||||
3. `mode_manager_update(&mode, now)` — RC liveness + CH6 mode
|
|
||||||
4. Remote e-stop block (from PR #69): `cdc_estop_request` → `rover_driver_estop()`
|
|
||||||
5. Tilt watchdog: `if (fabsf(imu.pitch) > ROVER_MAX_TILT_DEG) rover_driver_estop()`
|
|
||||||
6. RC CH5 arm/disarm (same hold interlock; pitch check relaxed to `< ROVER_MAX_TILT_DEG`)
|
|
||||||
7. USB arm/disarm (A/D commands)
|
|
||||||
8. Parse `rover_json_ready`: `sscanf(rover_json_buf, ...)` for `drive4` cmd → `rover_driver_set_cmd()`
|
|
||||||
9. RC direct drive fallback (MANUAL mode): CH3→speed all 4 wheels, CH4→differential steer
|
|
||||||
|
|
||||||
**50Hz ESC block:**
|
|
||||||
```c
|
|
||||||
if (rover.armed && !rover.estop) {
|
|
||||||
// If Jetson active: use stored fl/fr/rl/rr
|
|
||||||
// If RC MANUAL: compute fl=fr=rl=rr=speed, add steer diff
|
|
||||||
rover_driver_update(&rover, now);
|
|
||||||
} else {
|
|
||||||
rover_driver_update(&rover, now); // sends zeros
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**50Hz telemetry:**
|
|
||||||
```json
|
|
||||||
{"p":<pitch×10>,"r":<roll×10>,"s":<armed>,"fl":<fl>,"fr":<fr>,"rl":<rl>,"rr":<rr>,"md":<mode>,"es":<es>,"txc":<N>,"rxc":<N>}
|
|
||||||
```
|
|
||||||
Optional fields: heading, altitude/temp/pressure (same as balance bot).
|
|
||||||
|
|
||||||
**Status LEDs:** Same `status_update(now, imu_ok, rover.armed, |pitch|>ROVER_MAX_TILT_DEG, safety_remote_estop_active())`
|
|
||||||
|
|
||||||
**Arm guard:** `|pitch_deg| < ROVER_MAX_TILT_DEG` (45°) instead of `< 10.0f`
|
|
||||||
|
|
||||||
## Key Architectural Differences vs Balance Bot
|
|
||||||
|
|
||||||
| | Balance Bot | SaltyRover |
|
|
||||||
|---|---|---|
|
|
||||||
| PID loop | 1kHz balance PID | None |
|
|
||||||
| Motor cmd | `bal.motor_cmd` | Individual fl/fr/rl/rr |
|
|
||||||
| ESCs | 1× USART2 | 2× USART2 + UART5 |
|
|
||||||
| Tilt cutoff | 25° (balance state) | 45° (simple estop only) |
|
|
||||||
| Drive cmd | `C<spd>,<str>\n` | `{"cmd":"drive4",...}` JSON |
|
|
||||||
| Arm pitch guard | `< 10°` | `< 45°` |
|
|
||||||
| State machine | DISARMED/ARMED/TILT_FAULT | armed bool + estop bool |
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
1. Build: `pio run -e saltyrover` (or equivalent) — confirms compile
|
|
||||||
2. Hardware: send `{"cmd":"drive4","fl":200,"fr":200,"rl":200,"rr":200}` → all 4 wheels spin forward
|
|
||||||
3. Estop: send `E\n` → wheels stop, LED fast-blinks 200ms; `Z\n` + RC arm → resumes
|
|
||||||
4. Tilt: tilt rover >45° → wheels cut immediately; no cut at 30°
|
|
||||||
5. RC manual: CH5 arm + CH3/CH4 → wheels respond; CH5 disarm → stop
|
|
||||||
6. Telemetry: verify `"fl"/"fr"/"rl"/"rr"` in JSON stream, `"es"` field changes on estop
|
|
||||||
|
|
||||||
## Critical Files
|
|
||||||
- `src/main.c` — rover main loop (full rewrite of loop body)
|
|
||||||
- `include/rover_driver.h` — new file
|
|
||||||
- `src/rover_driver.c` — new file (UART5 init + two-ESC update)
|
|
||||||
- `include/config.h` — rover constants
|
|
||||||
- `include/safety.h` + `src/safety.c` — remote estop additions
|
|
||||||
- `lib/USB_CDC/src/usbd_cdc_if.c` — JSON '{' case + estop flags
|
|
||||||
- `lib/USB_CDC/include/usbd_cdc_if.h` — export new flags
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,21 +5,10 @@ from launch.actions import DeclareLaunchArgument
|
|||||||
import os
|
import os
|
||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
def generate_launch_description():
|
||||||
pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector")
|
pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector")
|
||||||
config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml")
|
config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml")
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument("config_file", default_value=config_file, description="Path to configuration YAML file"),
|
||||||
"config_file",
|
Node(package="saltybot_wheel_slip_detector", executable="wheel_slip_detector_node", name="wheel_slip_detector", output="screen", parameters=[LaunchConfiguration("config_file")]),
|
||||||
default_value=config_file,
|
|
||||||
description="Path to configuration YAML file",
|
|
||||||
),
|
|
||||||
Node(
|
|
||||||
package="saltybot_wheel_slip_detector",
|
|
||||||
executable="wheel_slip_detector_node",
|
|
||||||
name="wheel_slip_detector",
|
|
||||||
output="screen",
|
|
||||||
parameters=[LaunchConfiguration("config_file")],
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
|
|||||||
18
jetson/ros2_ws/src/saltybot_wheel_slip_detector/package.xml
Normal file
18
jetson/ros2_ws/src/saltybot_wheel_slip_detector/package.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>saltybot_wheel_slip_detector</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Wheel slip detection by comparing commanded vs actual velocity.</description>
|
||||||
|
<maintainer email="seb@vayrette.com">Seb</maintainer>
|
||||||
|
<license>Apache-2.0</license>
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>nav_msgs</depend>
|
||||||
|
<test_depend>pytest</test_depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@ -1,13 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Wheel slip detector for SaltyBot.
|
|
||||||
|
|
||||||
Detects wheel slip by comparing commanded velocity vs actual encoder velocity.
|
|
||||||
Publishes Bool when slip is detected for >0.5s, enabling speed reduction response.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from rclpy.timer import Timer
|
from rclpy.timer import Timer
|
||||||
@ -15,82 +8,60 @@ from geometry_msgs.msg import Twist
|
|||||||
from nav_msgs.msg import Odometry
|
from nav_msgs.msg import Odometry
|
||||||
from std_msgs.msg import Bool
|
from std_msgs.msg import Bool
|
||||||
|
|
||||||
|
|
||||||
class WheelSlipDetectorNode(Node):
|
class WheelSlipDetectorNode(Node):
|
||||||
"""ROS2 node for wheel slip detection."""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("wheel_slip_detector")
|
super().__init__("wheel_slip_detector")
|
||||||
|
|
||||||
self.declare_parameter("frequency", 10)
|
self.declare_parameter("frequency", 10)
|
||||||
frequency = self.get_parameter("frequency").value
|
frequency = self.get_parameter("frequency").value
|
||||||
self.declare_parameter("slip_threshold", 0.1)
|
self.declare_parameter("slip_threshold", 0.1)
|
||||||
self.declare_parameter("slip_timeout", 0.5)
|
self.declare_parameter("slip_timeout", 0.5)
|
||||||
|
|
||||||
self.slip_threshold = self.get_parameter("slip_threshold").value
|
self.slip_threshold = self.get_parameter("slip_threshold").value
|
||||||
self.slip_timeout = self.get_parameter("slip_timeout").value
|
self.slip_timeout = self.get_parameter("slip_timeout").value
|
||||||
self.period = 1.0 / frequency
|
self.period = 1.0 / frequency
|
||||||
|
|
||||||
self.cmd_vel: Optional[Twist] = None
|
self.cmd_vel: Optional[Twist] = None
|
||||||
self.actual_vel: Optional[Twist] = None
|
self.actual_vel: Optional[Twist] = None
|
||||||
self.slip_duration = 0.0
|
self.slip_duration = 0.0
|
||||||
self.slip_detected = False
|
self.slip_detected = False
|
||||||
|
|
||||||
self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10)
|
self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10)
|
||||||
self.create_subscription(Odometry, "/odom", self._on_odom, 10)
|
self.create_subscription(Odometry, "/odom", self._on_odom, 10)
|
||||||
self.pub_slip = self.create_publisher(Bool, "/saltybot/wheel_slip_detected", 10)
|
self.pub_slip = self.create_publisher(Bool, "/saltybot/wheel_slip_detected", 10)
|
||||||
self.timer: Timer = self.create_timer(self.period, self._timer_callback)
|
self.timer: Timer = self.create_timer(self.period, self._timer_callback)
|
||||||
|
self.get_logger().info(f"Wheel slip detector initialized at {frequency}Hz. Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s")
|
||||||
self.get_logger().info(
|
|
||||||
f"Wheel slip detector initialized at {frequency}Hz. "
|
|
||||||
f"Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_cmd_vel(self, msg: Twist) -> None:
|
def _on_cmd_vel(self, msg: Twist) -> None:
|
||||||
"""Update commanded velocity from subscription."""
|
|
||||||
self.cmd_vel = msg
|
self.cmd_vel = msg
|
||||||
|
|
||||||
def _on_odom(self, msg: Odometry) -> None:
|
def _on_odom(self, msg: Odometry) -> None:
|
||||||
"""Update actual velocity from odometry subscription."""
|
|
||||||
self.actual_vel = msg.twist.twist
|
self.actual_vel = msg.twist.twist
|
||||||
|
|
||||||
def _timer_callback(self) -> None:
|
def _timer_callback(self) -> None:
|
||||||
"""Detect wheel slip and publish detection flag."""
|
|
||||||
if self.cmd_vel is None or self.actual_vel is None:
|
if self.cmd_vel is None or self.actual_vel is None:
|
||||||
slip_detected = False
|
slip_detected = False
|
||||||
else:
|
else:
|
||||||
slip_detected = self._check_slip()
|
slip_detected = self._check_slip()
|
||||||
|
|
||||||
if slip_detected:
|
if slip_detected:
|
||||||
self.slip_duration += self.period
|
self.slip_duration += self.period
|
||||||
else:
|
else:
|
||||||
self.slip_duration = 0.0
|
self.slip_duration = 0.0
|
||||||
|
|
||||||
is_slip = self.slip_duration > self.slip_timeout
|
is_slip = self.slip_duration > self.slip_timeout
|
||||||
|
|
||||||
if is_slip != self.slip_detected:
|
if is_slip != self.slip_detected:
|
||||||
self.slip_detected = is_slip
|
self.slip_detected = is_slip
|
||||||
if self.slip_detected:
|
if self.slip_detected:
|
||||||
self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s")
|
self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s")
|
||||||
else:
|
else:
|
||||||
self.get_logger().info("Wheel slip cleared")
|
self.get_logger().info("Wheel slip cleared")
|
||||||
|
|
||||||
slip_msg = Bool()
|
slip_msg = Bool()
|
||||||
slip_msg.data = is_slip
|
slip_msg.data = is_slip
|
||||||
self.pub_slip.publish(slip_msg)
|
self.pub_slip.publish(slip_msg)
|
||||||
|
|
||||||
def _check_slip(self) -> bool:
|
def _check_slip(self) -> bool:
|
||||||
"""Check if velocity difference indicates slip."""
|
|
||||||
cmd_speed = math.sqrt(self.cmd_vel.linear.x**2 + self.cmd_vel.linear.y**2)
|
cmd_speed = math.sqrt(self.cmd_vel.linear.x**2 + self.cmd_vel.linear.y**2)
|
||||||
actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2)
|
actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2)
|
||||||
vel_diff = abs(cmd_speed - actual_speed)
|
vel_diff = abs(cmd_speed - actual_speed)
|
||||||
|
|
||||||
if cmd_speed < 0.05 and actual_speed < 0.05:
|
if cmd_speed < 0.05 and actual_speed < 0.05:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return vel_diff > self.slip_threshold
|
return vel_diff > self.slip_threshold
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = WheelSlipDetectorNode()
|
node = WheelSlipDetectorNode()
|
||||||
@ -102,6 +73,5 @@ def main(args=None):
|
|||||||
node.destroy_node()
|
node.destroy_node()
|
||||||
rclpy.shutdown()
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
[develop]
|
||||||
|
script-dir=$base/lib/saltybot_wheel_slip_detector
|
||||||
|
[install]
|
||||||
|
install-scripts=$base/lib/saltybot_wheel_slip_detector
|
||||||
@ -1,7 +1,5 @@
|
|||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
package_name = "saltybot_wheel_slip_detector"
|
package_name = "saltybot_wheel_slip_detector"
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
@ -19,9 +17,5 @@ setup(
|
|||||||
description="Wheel slip detection from velocity command/actual mismatch",
|
description="Wheel slip detection from velocity command/actual mismatch",
|
||||||
license="Apache-2.0",
|
license="Apache-2.0",
|
||||||
tests_require=["pytest"],
|
tests_require=["pytest"],
|
||||||
entry_points={
|
entry_points={"console_scripts": ["wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main"]},
|
||||||
"console_scripts": [
|
|
||||||
"wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
test/test_buzzer
BIN
test/test_buzzer
Binary file not shown.
BIN
test/test_fan
BIN
test/test_fan
Binary file not shown.
Binary file not shown.
@ -61,9 +61,6 @@ import { NetworkPanel } from './components/NetworkPanel.jsx';
|
|||||||
// Waypoint editor (issue #261)
|
// Waypoint editor (issue #261)
|
||||||
import { WaypointEditor } from './components/WaypointEditor.jsx';
|
import { WaypointEditor } from './components/WaypointEditor.jsx';
|
||||||
|
|
||||||
// Status header (issue #269)
|
|
||||||
import { StatusHeader } from './components/StatusHeader.jsx';
|
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
|
|||||||
@ -10,11 +10,13 @@
|
|||||||
* - Execute waypoint sequence with automatic progression
|
* - Execute waypoint sequence with automatic progression
|
||||||
* - Clear all waypoints button
|
* - Clear all waypoints button
|
||||||
* - Visual feedback for active waypoint (executing)
|
* - Visual feedback for active waypoint (executing)
|
||||||
|
* - Imports map display from MapViewer for coordinate system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
function WaypointEditor({ subscribe, publish, callService }) {
|
function WaypointEditor({ subscribe, publish, callService }) {
|
||||||
|
// Waypoint storage
|
||||||
const [waypoints, setWaypoints] = useState([]);
|
const [waypoints, setWaypoints] = useState([]);
|
||||||
const [selectedWaypoint, setSelectedWaypoint] = useState(null);
|
const [selectedWaypoint, setSelectedWaypoint] = useState(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@ -22,16 +24,20 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
const [activeWaypoint, setActiveWaypoint] = useState(null);
|
const [activeWaypoint, setActiveWaypoint] = useState(null);
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
|
|
||||||
|
// Map context
|
||||||
const [mapData, setMapData] = useState(null);
|
const [mapData, setMapData] = useState(null);
|
||||||
const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 });
|
const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 });
|
||||||
|
|
||||||
|
// Canvas reference
|
||||||
|
const canvasRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
// Refs for ROS integration
|
||||||
const mapDataRef = useRef(null);
|
const mapDataRef = useRef(null);
|
||||||
const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 });
|
const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 });
|
||||||
const waypointsRef = useRef([]);
|
const waypointsRef = useRef([]);
|
||||||
|
|
||||||
// Subscribe to map data
|
// Subscribe to map data (for coordinate reference)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMap = subscribe(
|
const unsubMap = subscribe(
|
||||||
'/map',
|
'/map',
|
||||||
@ -54,7 +60,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
return unsubMap;
|
return unsubMap;
|
||||||
}, [subscribe]);
|
}, [subscribe]);
|
||||||
|
|
||||||
// Subscribe to robot odometry
|
// Subscribe to robot odometry (for current position reference)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubOdom = subscribe(
|
const unsubOdom = subscribe(
|
||||||
'/odom',
|
'/odom',
|
||||||
@ -79,22 +85,29 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
return unsubOdom;
|
return unsubOdom;
|
||||||
}, [subscribe]);
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Canvas event handlers
|
||||||
const handleCanvasClick = (e) => {
|
const handleCanvasClick = (e) => {
|
||||||
if (!mapDataRef.current || !containerRef.current) return;
|
if (!mapDataRef.current || !canvasRef.current) return;
|
||||||
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const canvas = canvasRef.current;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left;
|
const clickX = e.clientX - rect.left;
|
||||||
const clickY = e.clientY - rect.top;
|
const clickY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Convert canvas coordinates to world coordinates
|
||||||
|
// This assumes the map is centered on the robot
|
||||||
|
const map = mapDataRef.current;
|
||||||
const robot = robotPoseRef.current;
|
const robot = robotPoseRef.current;
|
||||||
const zoom = 1;
|
const zoom = 1; // Would need to track zoom if map has zoom controls
|
||||||
|
|
||||||
const centerX = containerRef.current.clientWidth / 2;
|
// Inverse of map rendering calculation
|
||||||
const centerY = containerRef.current.clientHeight / 2;
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
|
||||||
const worldX = robot.x + (clickX - centerX) / zoom;
|
const worldX = robot.x + (clickX - centerX) / zoom;
|
||||||
const worldY = robot.y - (clickY - centerY) / zoom;
|
const worldY = robot.y - (clickY - centerY) / zoom;
|
||||||
|
|
||||||
|
// Create new waypoint
|
||||||
const newWaypoint = {
|
const newWaypoint = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
x: parseFloat(worldX.toFixed(2)),
|
x: parseFloat(worldX.toFixed(2)),
|
||||||
@ -106,6 +119,12 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
waypointsRef.current = [...waypointsRef.current, newWaypoint];
|
waypointsRef.current = [...waypointsRef.current, newWaypoint];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasContextMenu = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Right-click handled by waypoint list
|
||||||
|
};
|
||||||
|
|
||||||
|
// Waypoint list handlers
|
||||||
const handleDeleteWaypoint = (id) => {
|
const handleDeleteWaypoint = (id) => {
|
||||||
setWaypoints((prev) => prev.filter((wp) => wp.id !== id));
|
setWaypoints((prev) => prev.filter((wp) => wp.id !== id));
|
||||||
waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id);
|
waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id);
|
||||||
@ -139,12 +158,18 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
setDragIndex(null);
|
setDragIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Execute waypoints
|
||||||
const sendNavGoal = async (waypoint) => {
|
const sendNavGoal = async (waypoint) => {
|
||||||
if (!callService) return;
|
if (!callService) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Create quaternion from heading (default to 0 if no heading)
|
||||||
const heading = waypoint.theta || 0;
|
const heading = waypoint.theta || 0;
|
||||||
const halfHeading = heading / 2;
|
const halfHeading = heading / 2;
|
||||||
|
const qx = 0;
|
||||||
|
const qy = 0;
|
||||||
|
const qz = Math.sin(halfHeading);
|
||||||
|
const qw = Math.cos(halfHeading);
|
||||||
|
|
||||||
const goal = {
|
const goal = {
|
||||||
pose: {
|
pose: {
|
||||||
@ -154,14 +179,15 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
z: 0,
|
z: 0,
|
||||||
},
|
},
|
||||||
orientation: {
|
orientation: {
|
||||||
x: 0,
|
x: qx,
|
||||||
y: 0,
|
y: qy,
|
||||||
z: Math.sin(halfHeading),
|
z: qz,
|
||||||
w: Math.cos(halfHeading),
|
w: qw,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send to Nav2 navigate_to_pose action
|
||||||
await callService(
|
await callService(
|
||||||
'/navigate_to_pose',
|
'/navigate_to_pose',
|
||||||
'nav2_msgs/NavigateToPose',
|
'nav2_msgs/NavigateToPose',
|
||||||
@ -182,7 +208,11 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
for (const waypoint of waypoints) {
|
for (const waypoint of waypoints) {
|
||||||
const success = await sendNavGoal(waypoint);
|
const success = await sendNavGoal(waypoint);
|
||||||
if (!success) break;
|
if (!success) {
|
||||||
|
console.error('Failed to send goal for waypoint:', waypoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Wait a bit before sending next goal
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
@ -207,16 +237,21 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-3">
|
<div className="flex h-full gap-3">
|
||||||
{/* Map area */}
|
{/* Map area with click handlers */}
|
||||||
<div className="flex-1 flex flex-col space-y-3">
|
<div className="flex-1 flex flex-col space-y-3">
|
||||||
<div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden relative cursor-crosshair">
|
<div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden relative cursor-crosshair">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={handleCanvasContextMenu}
|
||||||
>
|
>
|
||||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" id="waypoint-overlay">
|
{/* Virtual map display - waypoints overlaid */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
id="waypoint-overlay"
|
||||||
|
>
|
||||||
|
{/* Waypoint markers */}
|
||||||
{waypoints.map((wp, idx) => {
|
{waypoints.map((wp, idx) => {
|
||||||
if (!mapDataRef.current) return null;
|
if (!mapDataRef.current) return null;
|
||||||
|
|
||||||
@ -233,6 +268,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={wp.id}>
|
<g key={wp.id}>
|
||||||
|
{/* Waypoint circle */}
|
||||||
<circle
|
<circle
|
||||||
cx={canvasX}
|
cx={canvasX}
|
||||||
cy={canvasY}
|
cy={canvasY}
|
||||||
@ -240,6 +276,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
fill={isActive ? '#ef4444' : isSelected ? '#fbbf24' : '#06b6d4'}
|
fill={isActive ? '#ef4444' : isSelected ? '#fbbf24' : '#06b6d4'}
|
||||||
opacity="0.8"
|
opacity="0.8"
|
||||||
/>
|
/>
|
||||||
|
{/* Waypoint number */}
|
||||||
<text
|
<text
|
||||||
x={canvasX}
|
x={canvasX}
|
||||||
y={canvasY}
|
y={canvasY}
|
||||||
@ -252,12 +289,19 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
>
|
>
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</text>
|
</text>
|
||||||
|
{/* Line to next waypoint */}
|
||||||
{idx < waypoints.length - 1 && (
|
{idx < waypoints.length - 1 && (
|
||||||
<line
|
<line
|
||||||
x1={canvasX}
|
x1={canvasX}
|
||||||
y1={canvasY}
|
y1={canvasY}
|
||||||
x2={centerX + (waypoints[idx + 1].x - robot.x) * zoom}
|
x2={
|
||||||
y2={centerY - (waypoints[idx + 1].y - robot.y) * zoom}
|
centerX +
|
||||||
|
(waypoints[idx + 1].x - robot.x) * zoom
|
||||||
|
}
|
||||||
|
y2={
|
||||||
|
centerY -
|
||||||
|
(waypoints[idx + 1].y - robot.y) * zoom
|
||||||
|
}
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
opacity="0.6"
|
opacity="0.6"
|
||||||
@ -266,6 +310,8 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Robot position marker */}
|
||||||
<circle
|
<circle
|
||||||
cx={containerRef.current?.clientWidth / 2 || 400}
|
cx={containerRef.current?.clientWidth / 2 || 400}
|
||||||
cy={containerRef.current?.clientHeight / 2 || 300}
|
cy={containerRef.current?.clientHeight / 2 || 300}
|
||||||
@ -311,7 +357,9 @@ function WaypointEditor({ subscribe, publish, callService }) {
|
|||||||
{/* Waypoint list */}
|
{/* Waypoint list */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-1">
|
<div className="flex-1 overflow-y-auto space-y-1">
|
||||||
{waypoints.length === 0 ? (
|
{waypoints.length === 0 ? (
|
||||||
<div className="text-center text-gray-700 text-xs py-4">Click map to add waypoints</div>
|
<div className="text-center text-gray-700 text-xs py-4">
|
||||||
|
Click map to add waypoints
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
waypoints.map((wp, idx) => (
|
waypoints.map((wp, idx) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user