Compare commits

..

2 Commits

Author SHA1 Message Date
5108fa8fa1 feat(controls): Wheel slip detector (Issue #262)
Detect wheel slip by comparing commanded velocity vs actual encoder velocity.
Publishes Bool flag on /saltybot/wheel_slip_detected when slip detected >0.5s.

Features:
- Subscribe to /cmd_vel (commanded) and /odom (actual velocity)
- Compare velocity magnitudes with 0.1 m/s threshold
- Persistence: slip must persist >0.5s to trigger (debounces transients)
- Publish Bool on /saltybot/wheel_slip_detected with detection status
- 10Hz monitoring frequency, configurable parameters

Algorithm:
- Compute linear speed from x,y components
- Calculate velocity difference
- If exceeds threshold: increment slip duration
- If duration > timeout: declare slip detected

Benefits:
- Detects environmental slip (ice, mud, wet surfaces)
- Triggers speed reduction to maintain traction
- Prevents wheel spinning/rut digging
- Safety response for loss of grip

Topics:
- Subscribed: /cmd_vel (Twist), /odom (Odometry)
- Published: /saltybot/wheel_slip_detected (Bool)

Config: frequency=10Hz, slip_threshold=0.1 m/s, slip_timeout=0.5s

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 17:29:03 -05:00
5362536fb1 feat(webui): waypoint editor with click-to-navigate (Issue #261) 2026-03-02 17:29:03 -05:00
80 changed files with 93 additions and 421 deletions

View File

@ -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.151.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.151.0) |
| `/saltybot/speed_limit` | Float32 | External (TBD) | ≥10Hz | Global speed limit (0.01.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 (50100ms @ 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

View File

@ -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

View File

@ -5,21 +5,10 @@ from launch.actions import DeclareLaunchArgument
import os
from ament_index_python.packages import get_package_share_directory
def generate_launch_description():
pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector")
config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml")
return LaunchDescription([
DeclareLaunchArgument(
"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")],
),
DeclareLaunchArgument("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")]),
])

View 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>

View File

@ -1,13 +1,6 @@
#!/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
import math
import rclpy
from rclpy.node import Node
from rclpy.timer import Timer
@ -15,82 +8,60 @@ from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from std_msgs.msg import Bool
class WheelSlipDetectorNode(Node):
"""ROS2 node for wheel slip detection."""
def __init__(self):
super().__init__("wheel_slip_detector")
self.declare_parameter("frequency", 10)
frequency = self.get_parameter("frequency").value
self.declare_parameter("slip_threshold", 0.1)
self.declare_parameter("slip_timeout", 0.5)
self.slip_threshold = self.get_parameter("slip_threshold").value
self.slip_timeout = self.get_parameter("slip_timeout").value
self.period = 1.0 / frequency
self.cmd_vel: Optional[Twist] = None
self.actual_vel: Optional[Twist] = None
self.slip_duration = 0.0
self.slip_detected = False
self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10)
self.create_subscription(Odometry, "/odom", self._on_odom, 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.get_logger().info(
f"Wheel slip detector initialized at {frequency}Hz. "
f"Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s"
)
self.get_logger().info(f"Wheel slip detector initialized at {frequency}Hz. Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s")
def _on_cmd_vel(self, msg: Twist) -> None:
"""Update commanded velocity from subscription."""
self.cmd_vel = msg
def _on_odom(self, msg: Odometry) -> None:
"""Update actual velocity from odometry subscription."""
self.actual_vel = msg.twist.twist
def _timer_callback(self) -> None:
"""Detect wheel slip and publish detection flag."""
if self.cmd_vel is None or self.actual_vel is None:
slip_detected = False
else:
slip_detected = self._check_slip()
if slip_detected:
self.slip_duration += self.period
else:
self.slip_duration = 0.0
is_slip = self.slip_duration > self.slip_timeout
if is_slip != self.slip_detected:
self.slip_detected = is_slip
if self.slip_detected:
self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s")
else:
self.get_logger().info("Wheel slip cleared")
slip_msg = Bool()
slip_msg.data = is_slip
self.pub_slip.publish(slip_msg)
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)
actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2)
vel_diff = abs(cmd_speed - actual_speed)
if cmd_speed < 0.05 and actual_speed < 0.05:
return False
return vel_diff > self.slip_threshold
def main(args=None):
rclpy.init(args=args)
node = WheelSlipDetectorNode()
@ -102,6 +73,5 @@ def main(args=None):
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,4 @@
[develop]
script-dir=$base/lib/saltybot_wheel_slip_detector
[install]
install-scripts=$base/lib/saltybot_wheel_slip_detector

View File

@ -1,7 +1,5 @@
from setuptools import find_packages, setup
package_name = "saltybot_wheel_slip_detector"
setup(
name=package_name,
version="0.1.0",
@ -19,9 +17,5 @@ setup(
description="Wheel slip detection from velocity command/actual mismatch",
license="Apache-2.0",
tests_require=["pytest"],
entry_points={
"console_scripts": [
"wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main",
],
},
entry_points={"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.

BIN
test_fan

Binary file not shown.

View File

@ -61,9 +61,6 @@ import { NetworkPanel } from './components/NetworkPanel.jsx';
// Waypoint editor (issue #261)
import { WaypointEditor } from './components/WaypointEditor.jsx';
// Status header (issue #269)
import { StatusHeader } from './components/StatusHeader.jsx';
const TAB_GROUPS = [
{
label: 'SOCIAL',

View File

@ -10,11 +10,13 @@
* - Execute waypoint sequence with automatic progression
* - Clear all waypoints button
* - Visual feedback for active waypoint (executing)
* - Imports map display from MapViewer for coordinate system
*/
import { useEffect, useRef, useState } from 'react';
function WaypointEditor({ subscribe, publish, callService }) {
// Waypoint storage
const [waypoints, setWaypoints] = useState([]);
const [selectedWaypoint, setSelectedWaypoint] = useState(null);
const [isDragging, setIsDragging] = useState(false);
@ -22,16 +24,20 @@ function WaypointEditor({ subscribe, publish, callService }) {
const [activeWaypoint, setActiveWaypoint] = useState(null);
const [executing, setExecuting] = useState(false);
// Map context
const [mapData, setMapData] = useState(null);
const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 });
// Canvas reference
const canvasRef = useRef(null);
const containerRef = useRef(null);
// Refs for ROS integration
const mapDataRef = useRef(null);
const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 });
const waypointsRef = useRef([]);
// Subscribe to map data
// Subscribe to map data (for coordinate reference)
useEffect(() => {
const unsubMap = subscribe(
'/map',
@ -54,7 +60,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
return unsubMap;
}, [subscribe]);
// Subscribe to robot odometry
// Subscribe to robot odometry (for current position reference)
useEffect(() => {
const unsubOdom = subscribe(
'/odom',
@ -79,22 +85,29 @@ function WaypointEditor({ subscribe, publish, callService }) {
return unsubOdom;
}, [subscribe]);
// Canvas event handlers
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 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 zoom = 1;
const zoom = 1; // Would need to track zoom if map has zoom controls
const centerX = containerRef.current.clientWidth / 2;
const centerY = containerRef.current.clientHeight / 2;
// Inverse of map rendering calculation
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const worldX = robot.x + (clickX - centerX) / zoom;
const worldY = robot.y - (clickY - centerY) / zoom;
// Create new waypoint
const newWaypoint = {
id: Date.now(),
x: parseFloat(worldX.toFixed(2)),
@ -106,6 +119,12 @@ function WaypointEditor({ subscribe, publish, callService }) {
waypointsRef.current = [...waypointsRef.current, newWaypoint];
};
const handleCanvasContextMenu = (e) => {
e.preventDefault();
// Right-click handled by waypoint list
};
// Waypoint list handlers
const handleDeleteWaypoint = (id) => {
setWaypoints((prev) => prev.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);
};
// Execute waypoints
const sendNavGoal = async (waypoint) => {
if (!callService) return;
try {
// Create quaternion from heading (default to 0 if no heading)
const heading = waypoint.theta || 0;
const halfHeading = heading / 2;
const qx = 0;
const qy = 0;
const qz = Math.sin(halfHeading);
const qw = Math.cos(halfHeading);
const goal = {
pose: {
@ -154,14 +179,15 @@ function WaypointEditor({ subscribe, publish, callService }) {
z: 0,
},
orientation: {
x: 0,
y: 0,
z: Math.sin(halfHeading),
w: Math.cos(halfHeading),
x: qx,
y: qy,
z: qz,
w: qw,
},
},
};
// Send to Nav2 navigate_to_pose action
await callService(
'/navigate_to_pose',
'nav2_msgs/NavigateToPose',
@ -182,7 +208,11 @@ function WaypointEditor({ subscribe, publish, callService }) {
setExecuting(true);
for (const waypoint of waypoints) {
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));
}
setExecuting(false);
@ -207,16 +237,21 @@ function WaypointEditor({ subscribe, publish, callService }) {
return (
<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 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden relative cursor-crosshair">
<div
ref={containerRef}
className="w-full h-full"
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) => {
if (!mapDataRef.current) return null;
@ -233,6 +268,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
return (
<g key={wp.id}>
{/* Waypoint circle */}
<circle
cx={canvasX}
cy={canvasY}
@ -240,6 +276,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
fill={isActive ? '#ef4444' : isSelected ? '#fbbf24' : '#06b6d4'}
opacity="0.8"
/>
{/* Waypoint number */}
<text
x={canvasX}
y={canvasY}
@ -252,12 +289,19 @@ function WaypointEditor({ subscribe, publish, callService }) {
>
{idx + 1}
</text>
{/* Line to next waypoint */}
{idx < waypoints.length - 1 && (
<line
x1={canvasX}
y1={canvasY}
x2={centerX + (waypoints[idx + 1].x - robot.x) * zoom}
y2={centerY - (waypoints[idx + 1].y - robot.y) * zoom}
x2={
centerX +
(waypoints[idx + 1].x - robot.x) * zoom
}
y2={
centerY -
(waypoints[idx + 1].y - robot.y) * zoom
}
stroke="#10b981"
strokeWidth="2"
opacity="0.6"
@ -266,6 +310,8 @@ function WaypointEditor({ subscribe, publish, callService }) {
</g>
);
})}
{/* Robot position marker */}
<circle
cx={containerRef.current?.clientWidth / 2 || 400}
cy={containerRef.current?.clientHeight / 2 || 300}
@ -311,7 +357,9 @@ function WaypointEditor({ subscribe, publish, callService }) {
{/* Waypoint list */}
<div className="flex-1 overflow-y-auto space-y-1">
{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) => (
<div