diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml
new file mode 100644
index 0000000..7bae9fe
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml
@@ -0,0 +1,5 @@
+wheel_slip_detector:
+ ros__parameters:
+ frequency: 10
+ slip_threshold: 0.1
+ slip_timeout: 0.5
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py
new file mode 100644
index 0000000..25436cd
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py
@@ -0,0 +1,25 @@
+from launch import LaunchDescription
+from launch_ros.actions import Node
+from launch.substitutions import LaunchConfiguration
+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")],
+ ),
+ ])
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/__pycache__/wheel_slip_detector_node.cpython-314.pyc b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/__pycache__/wheel_slip_detector_node.cpython-314.pyc
new file mode 100644
index 0000000..43dbb3c
Binary files /dev/null and b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/__pycache__/wheel_slip_detector_node.cpython-314.pyc differ
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py
new file mode 100644
index 0000000..856a758
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py
@@ -0,0 +1,107 @@
+#!/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
+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"
+ )
+
+ 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()
+ try:
+ rclpy.spin(node)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ node.destroy_node()
+ rclpy.shutdown()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py
new file mode 100644
index 0000000..19fd5d5
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py
@@ -0,0 +1,27 @@
+from setuptools import find_packages, setup
+
+package_name = "saltybot_wheel_slip_detector"
+
+setup(
+ name=package_name,
+ version="0.1.0",
+ packages=find_packages(exclude=["test"]),
+ data_files=[
+ ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
+ ("share/" + package_name, ["package.xml"]),
+ ("share/" + package_name + "/launch", ["launch/wheel_slip_detector.launch.py"]),
+ ("share/" + package_name + "/config", ["config/wheel_slip_config.yaml"]),
+ ],
+ install_requires=["setuptools"],
+ zip_safe=True,
+ maintainer="Seb",
+ maintainer_email="seb@vayrette.com",
+ 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",
+ ],
+ },
+)
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/__pycache__/test_wheel_slip_detector.cpython-314.pyc b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/__pycache__/test_wheel_slip_detector.cpython-314.pyc
new file mode 100644
index 0000000..77f7df3
Binary files /dev/null and b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/__pycache__/test_wheel_slip_detector.cpython-314.pyc differ
diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py
new file mode 100644
index 0000000..b2fc889
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py
@@ -0,0 +1,343 @@
+"""Unit tests for wheel_slip_detector_node."""
+
+import pytest
+import math
+from geometry_msgs.msg import Twist
+from nav_msgs.msg import Odometry
+from std_msgs.msg import Bool
+
+import rclpy
+
+from saltybot_wheel_slip_detector.wheel_slip_detector_node import WheelSlipDetectorNode
+
+
+@pytest.fixture
+def rclpy_fixture():
+ """Initialize and cleanup rclpy."""
+ rclpy.init()
+ yield
+ rclpy.shutdown()
+
+
+@pytest.fixture
+def node(rclpy_fixture):
+ """Create a wheel slip detector node instance."""
+ node = WheelSlipDetectorNode()
+ yield node
+ node.destroy_node()
+
+
+class TestNodeInitialization:
+ """Test suite for node initialization."""
+
+ def test_node_initialization(self, node):
+ """Test that node initializes with correct defaults."""
+ assert node.cmd_vel is None
+ assert node.actual_vel is None
+ assert node.slip_threshold == 0.1
+ assert node.slip_timeout == 0.5
+ assert node.slip_duration == 0.0
+ assert node.slip_detected is False
+
+ def test_frequency_parameter(self, node):
+ """Test frequency parameter is set correctly."""
+ frequency = node.get_parameter("frequency").value
+ assert frequency == 10
+
+ def test_slip_threshold_parameter(self, node):
+ """Test slip threshold parameter is set correctly."""
+ threshold = node.get_parameter("slip_threshold").value
+ assert threshold == 0.1
+
+ def test_slip_timeout_parameter(self, node):
+ """Test slip timeout parameter is set correctly."""
+ timeout = node.get_parameter("slip_timeout").value
+ assert timeout == 0.5
+
+ def test_period_calculation(self, node):
+ """Test that time period is correctly calculated from frequency."""
+ assert node.period == pytest.approx(0.1)
+
+
+class TestSubscriptions:
+ """Test suite for subscription handling."""
+
+ def test_cmd_vel_subscription(self, node):
+ """Test that cmd_vel subscription updates node state."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ cmd.linear.y = 0.5
+ node._on_cmd_vel(cmd)
+ assert node.cmd_vel is cmd
+ assert node.cmd_vel.linear.x == 1.0
+
+ def test_odom_subscription(self, node):
+ """Test that odometry subscription updates actual velocity."""
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.95
+ odom.twist.twist.linear.y = 0.48
+ node._on_odom(odom)
+ assert node.actual_vel is odom.twist.twist
+ assert node.actual_vel.linear.x == 0.95
+
+
+class TestSlipDetection:
+ """Test suite for slip detection logic."""
+
+ def test_no_slip_perfect_match(self, node):
+ """Test no slip when commanded equals actual."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 1.0
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is False
+
+ def test_no_slip_small_difference(self, node):
+ """Test no slip when difference is below threshold."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.95
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is False
+
+ def test_slip_exceeds_threshold(self, node):
+ """Test slip detection when difference exceeds threshold."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.85
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is True
+
+ def test_slip_large_difference(self, node):
+ """Test slip detection with large velocity difference."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is True
+
+ def test_no_slip_both_zero(self, node):
+ """Test no slip when both commanded and actual are zero."""
+ cmd = Twist()
+ cmd.linear.x = 0.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.0
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is False
+
+ def test_no_slip_both_near_zero(self, node):
+ """Test no slip when both are near zero (tolerance)."""
+ cmd = Twist()
+ cmd.linear.x = 0.01
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.02
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is False
+
+ def test_slip_2d_velocity(self, node):
+ """Test slip detection with 2D velocity (x and y)."""
+ cmd = Twist()
+ cmd.linear.x = 0.7
+ cmd.linear.y = 0.7
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ odom.twist.twist.linear.y = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ assert node._check_slip() is True
+
+
+class TestSlipPersistence:
+ """Test suite for slip persistence timing."""
+
+ def test_slip_not_triggered_immediately(self, node):
+ """Test that slip is not triggered immediately but requires timeout."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_duration > 0.0
+ assert node.slip_detected is False
+
+ def test_slip_declared_after_timeout(self, node):
+ """Test that slip is declared after timeout period."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ for _ in range(6):
+ node._timer_callback()
+ assert node.slip_detected is True
+
+ def test_slip_recovery_resets_duration(self, node):
+ """Test that slip duration resets when condition clears."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom1 = Odometry()
+ odom1.twist.twist.linear.x = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom1)
+ for _ in range(3):
+ node._timer_callback()
+ odom2 = Odometry()
+ odom2.twist.twist.linear.x = 1.0
+ node._on_odom(odom2)
+ node._timer_callback()
+ assert node.slip_duration == pytest.approx(0.0)
+ assert node.slip_detected is False
+
+ def test_slip_cumulative_time(self, node):
+ """Test that slip duration accumulates across callbacks."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ for _ in range(3):
+ node._timer_callback()
+ assert node.slip_duration == pytest.approx(0.3)
+ assert node.slip_detected is False
+ for _ in range(3):
+ node._timer_callback()
+ assert node.slip_duration == pytest.approx(0.6)
+ assert node.slip_detected is True
+
+
+class TestNoDataConditions:
+ """Test suite for behavior when sensor data is unavailable."""
+
+ def test_no_slip_without_cmd_vel(self, node):
+ """Test no slip declared when cmd_vel not received."""
+ node.cmd_vel = None
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is False
+
+ def test_no_slip_without_odometry(self, node):
+ """Test no slip declared when odometry not received."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ node._on_cmd_vel(cmd)
+ node.actual_vel = None
+ node._timer_callback()
+ assert node.slip_detected is False
+
+
+class TestScenarios:
+ """Integration-style tests for realistic scenarios."""
+
+ def test_scenario_normal_motion_no_slip(self, node):
+ """Scenario: Normal motion with good wheel traction."""
+ cmd = Twist()
+ cmd.linear.x = 0.5
+ for i in range(10):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5 + (i * 0.001)
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is False
+
+ def test_scenario_ice_slip_persistent(self, node):
+ """Scenario: Ice causes persistent wheel slip."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ for _ in range(10):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.7
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is True
+
+ def test_scenario_sandy_surface_intermittent_slip(self, node):
+ """Scenario: Sandy surface causes intermittent slip."""
+ cmd = Twist()
+ cmd.linear.x = 0.8
+ speeds = [0.7, 0.8, 0.6, 0.8, 0.7, 0.8]
+ for speed in speeds:
+ odom = Odometry()
+ odom.twist.twist.linear.x = speed
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is False
+
+ def test_scenario_sudden_obstacle_slip(self, node):
+ """Scenario: Robot hits obstacle and wheels slip."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ for _ in range(3):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 1.0
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ for _ in range(8):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.2
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is True
+
+ def test_scenario_wet_surface_recovery(self, node):
+ """Scenario: Wet surface slip, then wheel regains traction."""
+ cmd = Twist()
+ cmd.linear.x = 1.0
+ for _ in range(6):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.8
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is True
+ for _ in range(3):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 1.0
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is False
+
+ def test_scenario_backward_motion(self, node):
+ """Scenario: Backward motion with slip."""
+ cmd = Twist()
+ cmd.linear.x = -0.8
+ for _ in range(6):
+ odom = Odometry()
+ odom.twist.twist.linear.x = -0.4
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is True
+
+ def test_scenario_diagonal_motion_slip(self, node):
+ """Scenario: Diagonal motion with slip."""
+ cmd = Twist()
+ cmd.linear.x = 0.7
+ cmd.linear.y = 0.7
+ for _ in range(6):
+ odom = Odometry()
+ odom.twist.twist.linear.x = 0.5
+ odom.twist.twist.linear.y = 0.5
+ node._on_cmd_vel(cmd)
+ node._on_odom(odom)
+ node._timer_callback()
+ assert node.slip_detected is True
diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx
index 8e4be5f..73e1876 100644
--- a/ui/social-bot/src/App.jsx
+++ b/ui/social-bot/src/App.jsx
@@ -58,6 +58,9 @@ import JoystickTeleop from './components/JoystickTeleop.jsx';
// Network diagnostics (issue #222)
import { NetworkPanel } from './components/NetworkPanel.jsx';
+// Waypoint editor (issue #261)
+import { WaypointEditor } from './components/WaypointEditor.jsx';
+
const TAB_GROUPS = [
{
label: 'SOCIAL',
@@ -85,6 +88,13 @@ const TAB_GROUPS = [
{ id: 'cameras', label: 'Cameras', },
],
},
+ {
+ label: 'NAVIGATION',
+ color: 'text-teal-600',
+ tabs: [
+ { id: 'waypoints', label: 'Waypoints' },
+ ],
+ },
{
label: 'FLEET',
color: 'text-green-600',
@@ -244,8 +254,10 @@ export default function App() {
)}
- {activeTab === 'health' &&