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 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")],
),
]) ])

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 #!/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()

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

BIN
test_fan

Binary file not shown.

View File

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

View File

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