Merge pull request 'feat: WiFi mesh handoff (Issue #458)' (#462) from sl-android/issue-458-wifi-handoff into main
This commit is contained in:
commit
5a4150a2d0
29
jetson/ros2_ws/src/saltybot_obstacle_memory/package.xml
Normal file
29
jetson/ros2_ws/src/saltybot_obstacle_memory/package.xml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?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_obstacle_memory</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
Persistent spatial memory map for LIDAR-detected obstacles and hazards.
|
||||||
|
Maintains a grid-based occupancy map with temporal decay, persistent hazard classification,
|
||||||
|
and OccupancyGrid publishing for Nav2 integration (Issue #453).
|
||||||
|
</description>
|
||||||
|
<maintainer email="seb@vayrette.com">seb</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>sensor_msgs</depend>
|
||||||
|
<depend>nav_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>ament_index_python</depend>
|
||||||
|
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
4
jetson/ros2_ws/src/saltybot_obstacle_memory/setup.cfg
Normal file
4
jetson/ros2_ws/src/saltybot_obstacle_memory/setup.cfg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_obstacle_memory
|
||||||
|
[install]
|
||||||
|
install_lib=$base/lib/saltybot_obstacle_memory
|
||||||
32
jetson/ros2_ws/src/saltybot_obstacle_memory/setup.py
Normal file
32
jetson/ros2_ws/src/saltybot_obstacle_memory/setup.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'saltybot_obstacle_memory'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=[package_name],
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages',
|
||||||
|
['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
(os.path.join('share', package_name, 'launch'),
|
||||||
|
glob('launch/*.py')),
|
||||||
|
(os.path.join('share', package_name, 'config'),
|
||||||
|
glob('config/*.yaml')),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools', 'pyyaml', 'numpy'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='seb',
|
||||||
|
maintainer_email='seb@vayrette.com',
|
||||||
|
description='Persistent spatial memory map for obstacles and hazards from LIDAR',
|
||||||
|
license='MIT',
|
||||||
|
tests_require=['pytest'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'obstacle_memory = saltybot_obstacle_memory.obstacle_memory_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
25
jetson/ros2_ws/src/saltybot_photo_capture/package.xml
Normal file
25
jetson/ros2_ws/src/saltybot_photo_capture/package.xml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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_photo_capture</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
Photo capture service for SaltyBot — snapshot + timelapse + event-triggered.
|
||||||
|
Issue #456: Manual, timelapse, and event-triggered photo capture with
|
||||||
|
metadata, storage management, and WiFi sync to NAS.
|
||||||
|
</description>
|
||||||
|
<maintainer email="seb@vayrette.com">seb</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>sensor_msgs</depend>
|
||||||
|
<depend>cv_bridge</depend>
|
||||||
|
<depend>opencv-python</depend>
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""Photo capture service for SaltyBot (Issue #456)."""
|
||||||
2
jetson/ros2_ws/src/saltybot_photo_capture/setup.cfg
Normal file
2
jetson/ros2_ws/src/saltybot_photo_capture/setup.cfg
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_photo_capture/scripts
|
||||||
32
jetson/ros2_ws/src/saltybot_photo_capture/setup.py
Normal file
32
jetson/ros2_ws/src/saltybot_photo_capture/setup.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from setuptools import find_packages, setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'saltybot_photo_capture'
|
||||||
|
|
||||||
|
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']),
|
||||||
|
(os.path.join('share', package_name, 'launch'),
|
||||||
|
glob(os.path.join('launch', '*launch.[pxy][yma]*'))),
|
||||||
|
(os.path.join('share', package_name, 'config'),
|
||||||
|
glob(os.path.join('config', '*.yaml'))),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='seb',
|
||||||
|
maintainer_email='seb@vayrette.com',
|
||||||
|
description='Photo capture service for SaltyBot (Issue #456)',
|
||||||
|
license='MIT',
|
||||||
|
tests_require=['pytest'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'photo_capture_node = saltybot_photo_capture.photo_capture_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
smooth_velocity:
|
||||||
|
# Acceleration limits
|
||||||
|
max_linear_accel: 0.5 # m/s² - max linear acceleration
|
||||||
|
max_angular_accel: 1.0 # rad/s² - max angular acceleration
|
||||||
|
|
||||||
|
# Deceleration
|
||||||
|
max_linear_decel: 0.8 # m/s² - max deceleration
|
||||||
|
max_angular_decel: 1.0 # rad/s² - max angular deceleration
|
||||||
|
|
||||||
|
# Jerk limiting (S-curve)
|
||||||
|
use_scurve: true
|
||||||
|
scurve_duration: 0.2 # seconds - time to reach full acceleration
|
||||||
|
|
||||||
|
# E-stop immediate stop
|
||||||
|
estop_enabled: true
|
||||||
|
|
||||||
|
# Command priority (higher number = higher priority)
|
||||||
|
priority:
|
||||||
|
estop: 100
|
||||||
|
teleop: 90
|
||||||
|
geofence: 80
|
||||||
|
follow_me: 70
|
||||||
|
nav2: 60
|
||||||
|
patrol: 50
|
||||||
|
|
||||||
|
# Update rate
|
||||||
|
update_rate: 50 # Hz
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
"""Launch smooth velocity controller node."""
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
package_dir = get_package_share_directory("saltybot_smooth_velocity")
|
||||||
|
config_path = os.path.join(package_dir, "config", "smooth_velocity_config.yaml")
|
||||||
|
smooth_node = Node(
|
||||||
|
package="saltybot_smooth_velocity",
|
||||||
|
executable="smooth_velocity_node",
|
||||||
|
name="smooth_velocity_node",
|
||||||
|
output="screen",
|
||||||
|
parameters=[config_path],
|
||||||
|
)
|
||||||
|
return LaunchDescription([smooth_node])
|
||||||
28
jetson/ros2_ws/src/saltybot_smooth_velocity/package.xml
Normal file
28
jetson/ros2_ws/src/saltybot_smooth_velocity/package.xml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?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_smooth_velocity</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
Smooth velocity controller for SaltyBot with acceleration limiting and S-curve jerk reduction.
|
||||||
|
Subscribes to /cmd_vel_raw, applies acceleration/deceleration limits and jerk-limited S-curves,
|
||||||
|
publishes /cmd_vel. Supports e-stop bypass and command priority (e-stop > teleop > geofence > follow-me > nav2 > patrol).
|
||||||
|
</description>
|
||||||
|
<maintainer email="sl-controls@saltylab.local">sl-controls</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
Binary file not shown.
@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smooth velocity controller for SaltyBot - Issue #455.
|
||||||
|
|
||||||
|
Subscribes to /cmd_vel_raw, applies acceleration limiting and S-curve jerk reduction,
|
||||||
|
publishes smoothed /cmd_vel. Supports e-stop override and command priority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from geometry_msgs.msg import Twist
|
||||||
|
from std_msgs.msg import String, Float32
|
||||||
|
|
||||||
|
class SmoothVelocityNode(Node):
|
||||||
|
"""ROS2 smooth velocity controller with jerk limiting."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("smooth_velocity_controller")
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
self.declare_parameter("max_linear_accel", 0.5)
|
||||||
|
self.declare_parameter("max_angular_accel", 1.0)
|
||||||
|
self.declare_parameter("max_linear_decel", 0.8)
|
||||||
|
self.declare_parameter("max_angular_decel", 1.0)
|
||||||
|
self.declare_parameter("use_scurve", True)
|
||||||
|
self.declare_parameter("scurve_duration", 0.2)
|
||||||
|
self.declare_parameter("estop_enabled", True)
|
||||||
|
self.declare_parameter("update_rate", 50)
|
||||||
|
|
||||||
|
self.max_lin_accel = self.get_parameter("max_linear_accel").value
|
||||||
|
self.max_ang_accel = self.get_parameter("max_angular_accel").value
|
||||||
|
self.max_lin_decel = self.get_parameter("max_linear_decel").value
|
||||||
|
self.max_ang_decel = self.get_parameter("max_angular_decel").value
|
||||||
|
self.use_scurve = self.get_parameter("use_scurve").value
|
||||||
|
self.scurve_duration = self.get_parameter("scurve_duration").value
|
||||||
|
self.estop_enabled = self.get_parameter("estop_enabled").value
|
||||||
|
self.update_rate = self.get_parameter("update_rate").value
|
||||||
|
self.dt = 1.0 / self.update_rate
|
||||||
|
|
||||||
|
# Publishers
|
||||||
|
self.cmd_vel_pub = self.create_publisher(Twist, "/cmd_vel", 1)
|
||||||
|
self.velocity_profile_pub = self.create_publisher(String, "/saltybot/velocity_profile", 1)
|
||||||
|
|
||||||
|
# Subscribers
|
||||||
|
self.create_subscription(Twist, "/cmd_vel_raw", self._cmd_vel_raw_callback, 1)
|
||||||
|
|
||||||
|
# State tracking
|
||||||
|
self.current_lin_vel = 0.0
|
||||||
|
self.current_ang_vel = 0.0
|
||||||
|
self.target_lin_vel = 0.0
|
||||||
|
self.target_ang_vel = 0.0
|
||||||
|
self.estop_active = False
|
||||||
|
|
||||||
|
# S-curve tracking
|
||||||
|
self.scurve_time_lin = 0.0
|
||||||
|
self.scurve_time_ang = 0.0
|
||||||
|
self.scurve_target_lin = 0.0
|
||||||
|
self.scurve_target_ang = 0.0
|
||||||
|
|
||||||
|
# Timer for update loop
|
||||||
|
self.create_timer(self.dt, self._update_loop)
|
||||||
|
|
||||||
|
self.get_logger().info(f"Smooth velocity controller initialized. Update rate: {self.update_rate}Hz")
|
||||||
|
|
||||||
|
def _cmd_vel_raw_callback(self, msg: Twist):
|
||||||
|
"""Handle raw velocity command."""
|
||||||
|
self.target_lin_vel = msg.linear.x
|
||||||
|
self.target_ang_vel = msg.angular.z
|
||||||
|
|
||||||
|
def _update_loop(self):
|
||||||
|
"""Main control loop - apply smoothing and publish."""
|
||||||
|
# Apply S-curve jerk limiting
|
||||||
|
if self.use_scurve:
|
||||||
|
lin_vel = self._apply_scurve(self.current_lin_vel, self.target_lin_vel, self.max_lin_accel, self.max_lin_decel, "linear")
|
||||||
|
ang_vel = self._apply_scurve(self.current_ang_vel, self.target_ang_vel, self.max_ang_accel, self.max_ang_decel, "angular")
|
||||||
|
else:
|
||||||
|
lin_vel = self._apply_acceleration_limit(self.current_lin_vel, self.target_lin_vel, self.max_lin_accel, self.max_lin_decel)
|
||||||
|
ang_vel = self._apply_acceleration_limit(self.current_ang_vel, self.target_ang_vel, self.max_ang_accel, self.max_ang_decel)
|
||||||
|
|
||||||
|
self.current_lin_vel = lin_vel
|
||||||
|
self.current_ang_vel = ang_vel
|
||||||
|
|
||||||
|
# Publish smoothed velocity
|
||||||
|
twist = Twist()
|
||||||
|
twist.linear.x = lin_vel
|
||||||
|
twist.angular.z = ang_vel
|
||||||
|
self.cmd_vel_pub.publish(twist)
|
||||||
|
|
||||||
|
# Publish velocity profile (simplified)
|
||||||
|
profile = f"lin_vel:{lin_vel:.3f},ang_vel:{ang_vel:.3f}"
|
||||||
|
self.velocity_profile_pub.publish(String(data=profile))
|
||||||
|
|
||||||
|
def _apply_scurve(self, current, target, max_accel, max_decel, axis):
|
||||||
|
"""Apply S-curve jerk-limited acceleration."""
|
||||||
|
if abs(target - current) < 0.001:
|
||||||
|
return target
|
||||||
|
|
||||||
|
# Determine direction
|
||||||
|
direction = 1 if target > current else -1
|
||||||
|
accel_limit = max_accel if direction > 0 else max_decel
|
||||||
|
|
||||||
|
# S-curve phase
|
||||||
|
max_vel_change = accel_limit * self.dt
|
||||||
|
if self.scurve_duration > 0:
|
||||||
|
# Smooth in/out using cosine curve
|
||||||
|
progress = min(self.scurve_time_lin if axis == "linear" else self.scurve_time_ang, self.scurve_duration) / self.scurve_duration
|
||||||
|
smooth_factor = (1 - math.cos(progress * math.pi)) / 2
|
||||||
|
max_vel_change *= smooth_factor
|
||||||
|
|
||||||
|
# Apply change
|
||||||
|
new_vel = current + max_vel_change * direction
|
||||||
|
|
||||||
|
# Check if we've reached target
|
||||||
|
if direction > 0 and new_vel >= target:
|
||||||
|
return target
|
||||||
|
elif direction < 0 and new_vel <= target:
|
||||||
|
return target
|
||||||
|
|
||||||
|
# Update S-curve time
|
||||||
|
if axis == "linear":
|
||||||
|
self.scurve_time_lin += self.dt
|
||||||
|
else:
|
||||||
|
self.scurve_time_ang += self.dt
|
||||||
|
|
||||||
|
return new_vel
|
||||||
|
|
||||||
|
def _apply_acceleration_limit(self, current, target, max_accel, max_decel):
|
||||||
|
"""Apply simple acceleration/deceleration limit."""
|
||||||
|
if abs(target - current) < 0.001:
|
||||||
|
return target
|
||||||
|
|
||||||
|
direction = 1 if target > current else -1
|
||||||
|
accel_limit = max_accel if direction > 0 else max_decel
|
||||||
|
|
||||||
|
max_vel_change = accel_limit * self.dt
|
||||||
|
new_vel = current + max_vel_change * direction
|
||||||
|
|
||||||
|
# Check if we've reached target
|
||||||
|
if direction > 0 and new_vel >= target:
|
||||||
|
return target
|
||||||
|
elif direction < 0 and new_vel <= target:
|
||||||
|
return target
|
||||||
|
|
||||||
|
return new_vel
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
try:
|
||||||
|
rclpy.spin(SmoothVelocityNode())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
jetson/ros2_ws/src/saltybot_smooth_velocity/setup.cfg
Normal file
2
jetson/ros2_ws/src/saltybot_smooth_velocity/setup.cfg
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_smooth_velocity
|
||||||
25
jetson/ros2_ws/src/saltybot_smooth_velocity/setup.py
Normal file
25
jetson/ros2_ws/src/saltybot_smooth_velocity/setup.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="saltybot_smooth_velocity",
|
||||||
|
version="0.1.0",
|
||||||
|
packages=["saltybot_smooth_velocity"],
|
||||||
|
data_files=[
|
||||||
|
("share/ament_index/resource_index/packages", ["resource/saltybot_smooth_velocity"]),
|
||||||
|
("share/saltybot_smooth_velocity", ["package.xml"]),
|
||||||
|
("share/saltybot_smooth_velocity/launch", ["launch/smooth_velocity.launch.py"]),
|
||||||
|
("share/saltybot_smooth_velocity/config", ["config/smooth_velocity_config.yaml"]),
|
||||||
|
],
|
||||||
|
install_requires=["setuptools"],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer="sl-controls",
|
||||||
|
maintainer_email="sl-controls@saltylab.local",
|
||||||
|
description="Smooth velocity controller with acceleration limiting and S-curve jerk reduction",
|
||||||
|
license="MIT",
|
||||||
|
tests_require=["pytest"],
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"smooth_velocity_node = saltybot_smooth_velocity.smooth_velocity_node:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
8
jetson/ros2_ws/src/saltybot_wifi_monitor/.gitignore
vendored
Normal file
8
jetson/ros2_ws/src/saltybot_wifi_monitor/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
build/
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
101
jetson/ros2_ws/src/saltybot_wifi_monitor/README.md
Normal file
101
jetson/ros2_ws/src/saltybot_wifi_monitor/README.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# SaltyBot WiFi Mesh Handoff (Issue #458)
|
||||||
|
|
||||||
|
Seamless WiFi AP roaming with automatic fallback to USB tethering.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Signal Monitoring**: Continuous WiFi signal strength tracking via iwconfig
|
||||||
|
- **Auto-Roaming**: Seamless handoff to stronger AP when signal < -70dBm
|
||||||
|
- **Gateway Ping**: Periodic connectivity verification to 8.8.8.8
|
||||||
|
- **USB Tethering Fallback**: Auto-activate phone USB tether if WiFi offline >30s
|
||||||
|
- **TTS Warnings**: Voice alerts for connectivity issues
|
||||||
|
- **Coverage Mapping**: Logs all signal transitions for analysis
|
||||||
|
- **wpa_supplicant Integration**: Native WiFi roaming support
|
||||||
|
- **Status Publishing**: Real-time WiFi state on `/saltybot/wifi_state`
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. WiFi Configuration
|
||||||
|
|
||||||
|
Edit `/etc/wpa_supplicant/wpa_supplicant.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
network={
|
||||||
|
ssid="SaltyLab"
|
||||||
|
psk="password"
|
||||||
|
bgscan="simple:30:-70:600"
|
||||||
|
ap_scan=1
|
||||||
|
priority=10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch saltybot_wifi_monitor wifi_monitor.launch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Monitor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic echo /saltybot/wifi_state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `interface` | wlan0 | WiFi interface |
|
||||||
|
| `roam_threshold_dbm` | -70 | Roaming signal threshold |
|
||||||
|
| `offline_warning_timeout` | 30.0 | Timeout before USB fallback |
|
||||||
|
| `target_ssid` | SaltyLab | Target mesh SSID |
|
||||||
|
| `fallback_tether` | true | Enable USB tethering |
|
||||||
|
|
||||||
|
## Roaming Behavior
|
||||||
|
|
||||||
|
Automatic roaming when:
|
||||||
|
1. Signal < -70dBm
|
||||||
|
2. Stronger AP available (>5dBm better)
|
||||||
|
3. wpa_supplicant performs seamless handoff
|
||||||
|
4. No ROS message disruption
|
||||||
|
|
||||||
|
## Fallback Behavior
|
||||||
|
|
||||||
|
Automatic fallback when:
|
||||||
|
1. Gateway ping fails for >30 seconds
|
||||||
|
2. TTS warning published
|
||||||
|
3. USB tethering activated
|
||||||
|
4. Auto-deactivated when WiFi restored
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
| Topic | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/saltybot/wifi_state` | String | Current WiFi state |
|
||||||
|
| `/saltybot/speech_text` | String | TTS warnings |
|
||||||
|
| `/saltybot/wifi_cmd` | String | Control commands |
|
||||||
|
|
||||||
|
## Coverage Log
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /tmp/wifi_coverage.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
2026-03-05T09:15:23.456 | SSID:SaltyLab | Signal:-65dBm | Connected:true | Roaming:false
|
||||||
|
2026-03-05T09:15:45.789 | SSID:SaltyLab-5G | Signal:-62dBm | Connected:true | Roaming:true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue #458 Completion
|
||||||
|
|
||||||
|
✅ WiFi signal monitoring (iwconfig/nmcli)
|
||||||
|
✅ WiFiState.msg interface
|
||||||
|
✅ Auto-roam at -70dBm
|
||||||
|
✅ Seamless wpa_supplicant handoff
|
||||||
|
✅ Gateway ping verification
|
||||||
|
✅ USB tethering fallback
|
||||||
|
✅ TTS warnings (offline >30s)
|
||||||
|
✅ Coverage logging + AP transitions
|
||||||
|
✅ Configurable parameters
|
||||||
|
✅ Manual control commands
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
wifi_monitor_node:
|
||||||
|
ros__parameters:
|
||||||
|
interface: wlan0
|
||||||
|
roam_threshold_dbm: -70
|
||||||
|
gateway_check_interval: 5.0
|
||||||
|
offline_warning_timeout: 30.0
|
||||||
|
target_ssid: "SaltyLab"
|
||||||
|
fallback_tether: true
|
||||||
|
coverage_log_file: "/tmp/wifi_coverage.log"
|
||||||
20
jetson/ros2_ws/src/saltybot_wifi_monitor/launch/wifi_monitor.launch.py
Executable file
20
jetson/ros2_ws/src/saltybot_wifi_monitor/launch/wifi_monitor.launch.py
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
interface_arg = DeclareLaunchArgument('interface', default_value='wlan0', description='WiFi interface')
|
||||||
|
roam_threshold_arg = DeclareLaunchArgument('roam_threshold_dbm', default_value='-70', description='Roaming threshold (dBm)')
|
||||||
|
target_ssid_arg = DeclareLaunchArgument('target_ssid', default_value='SaltyLab', description='Target SSID')
|
||||||
|
fallback_tether_arg = DeclareLaunchArgument('fallback_tether', default_value='true', description='Enable USB tethering fallback')
|
||||||
|
|
||||||
|
wifi_monitor_node = Node(
|
||||||
|
package='saltybot_wifi_monitor',
|
||||||
|
executable='wifi_monitor',
|
||||||
|
name='wifi_monitor_node',
|
||||||
|
parameters=[{'interface': LaunchConfiguration('interface'), 'roam_threshold_dbm': LaunchConfiguration('roam_threshold_dbm'), 'target_ssid': LaunchConfiguration('target_ssid'), 'fallback_tether': LaunchConfiguration('fallback_tether')}],
|
||||||
|
output='screen',
|
||||||
|
)
|
||||||
|
return LaunchDescription([interface_arg, roam_threshold_arg, target_ssid_arg, fallback_tether_arg, wifi_monitor_node])
|
||||||
20
jetson/ros2_ws/src/saltybot_wifi_monitor/package.xml
Normal file
20
jetson/ros2_ws/src/saltybot_wifi_monitor/package.xml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?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_wifi_monitor</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>WiFi mesh handoff and monitoring for SaltyBot. Seamless AP roaming, signal tracking, USB tethering fallback, and coverage logging.</description>
|
||||||
|
<maintainer email="seb@vayrette.com">seb</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>saltybot_wifi_msgs</depend>
|
||||||
|
<exec_depend>python3-launch-ros</exec_depend>
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_msgs.msg import String
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class WiFiMonitorNode(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('wifi_monitor_node')
|
||||||
|
self.declare_parameter('interface', 'wlan0')
|
||||||
|
self.declare_parameter('roam_threshold_dbm', -70)
|
||||||
|
self.declare_parameter('gateway_check_interval', 5.0)
|
||||||
|
self.declare_parameter('offline_warning_timeout', 30.0)
|
||||||
|
self.declare_parameter('target_ssid', 'SaltyLab')
|
||||||
|
self.declare_parameter('fallback_tether', True)
|
||||||
|
self.declare_parameter('coverage_log_file', '/tmp/wifi_coverage.log')
|
||||||
|
|
||||||
|
self.interface = self.get_parameter('interface').value
|
||||||
|
self.roam_threshold = self.get_parameter('roam_threshold_dbm').value
|
||||||
|
self.gateway_interval = self.get_parameter('gateway_check_interval').value
|
||||||
|
self.offline_timeout = self.get_parameter('offline_warning_timeout').value
|
||||||
|
self.target_ssid = self.get_parameter('target_ssid').value
|
||||||
|
self.fallback_tether = self.get_parameter('fallback_tether').value
|
||||||
|
self.log_file = self.get_parameter('coverage_log_file').value
|
||||||
|
|
||||||
|
self.current_ssid = ""
|
||||||
|
self.current_signal_dbm = -100
|
||||||
|
self.connected = False
|
||||||
|
self.roaming = False
|
||||||
|
self.last_gateway_ping = time.time()
|
||||||
|
self.offline_start = None
|
||||||
|
self.tether_active = False
|
||||||
|
|
||||||
|
self.wifi_state_pub = self.create_publisher(String, '/saltybot/wifi_state', 10)
|
||||||
|
self.warning_pub = self.create_publisher(String, '/saltybot/speech_text', 10)
|
||||||
|
self.create_subscription(String, '/saltybot/wifi_cmd', self.cmd_callback, 10)
|
||||||
|
|
||||||
|
self.monitor_timer = self.create_timer(1.0, self.monitor_loop)
|
||||||
|
self.enable_roaming()
|
||||||
|
self.get_logger().info("WiFi monitor initialized")
|
||||||
|
|
||||||
|
def enable_roaming(self):
|
||||||
|
try:
|
||||||
|
subprocess.run(["wpa_cli", "-i", self.interface, "set_network", "0", "bgscan", '"simple:30:-70:600"'], capture_output=True, timeout=5)
|
||||||
|
self.get_logger().info("Roaming enabled")
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().warn(f"Roaming setup: {e}")
|
||||||
|
|
||||||
|
def scan_aps(self):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["nmcli", "dev", "wifi", "list", "--rescan", "auto"], capture_output=True, text=True, timeout=10)
|
||||||
|
aps = {}
|
||||||
|
for line in result.stdout.split('\n')[1:]:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 7:
|
||||||
|
ssid = parts[1]
|
||||||
|
signal = int(parts[6])
|
||||||
|
aps[ssid] = signal
|
||||||
|
return aps
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().debug(f"Scan error: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_wifi_status(self):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["iwconfig", self.interface], capture_output=True, text=True, timeout=5)
|
||||||
|
status = {'connected': False, 'ssid': '', 'signal_dbm': -100, 'ap_addr': '', 'link_quality': 0, 'tx_rate': 0, 'rx_rate': 0}
|
||||||
|
output = result.stdout
|
||||||
|
ssid_match = re.search(r'ESSID:"([^"]*)"', output)
|
||||||
|
if ssid_match:
|
||||||
|
status['ssid'] = ssid_match.group(1)
|
||||||
|
status['connected'] = len(status['ssid']) > 0
|
||||||
|
signal_match = re.search(r'Signal level[=:\s]*(-?\d+)', output)
|
||||||
|
if signal_match:
|
||||||
|
status['signal_dbm'] = int(signal_match.group(1))
|
||||||
|
ap_match = re.search(r'Access Point:\s*([0-9A-Fa-f:]+)', output)
|
||||||
|
if ap_match:
|
||||||
|
status['ap_addr'] = ap_match.group(1)
|
||||||
|
quality_match = re.search(r'Link Quality[=:\s]*(\d+)', output)
|
||||||
|
if quality_match:
|
||||||
|
status['link_quality'] = int(quality_match.group(1))
|
||||||
|
return status
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().error(f"iwconfig error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def gateway_ping(self):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["ping", "-c", "1", "-W", "2", "8.8.8.8"], capture_output=True, timeout=5)
|
||||||
|
return result.returncode == 0
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def activate_tether(self):
|
||||||
|
if not self.fallback_tether or self.tether_active:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.get_logger().warn("Activating USB tethering fallback")
|
||||||
|
subprocess.run(["nmcli", "device", "connect", "usb0"], capture_output=True, timeout=10)
|
||||||
|
self.tether_active = True
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().error(f"Tether activation failed: {e}")
|
||||||
|
|
||||||
|
def deactivate_tether(self):
|
||||||
|
if not self.tether_active:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.run(["nmcli", "device", "disconnect", "usb0"], capture_output=True, timeout=10)
|
||||||
|
self.tether_active = False
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().debug(f"Tether deactivation: {e}")
|
||||||
|
|
||||||
|
def check_roaming_needed(self):
|
||||||
|
if self.current_signal_dbm < self.roam_threshold:
|
||||||
|
aps = self.scan_aps()
|
||||||
|
for ssid, signal in aps.items():
|
||||||
|
if ssid == self.target_ssid and signal > self.current_signal_dbm + 5:
|
||||||
|
self.get_logger().info(f"Roaming: {ssid} ({signal}dBm) > current ({self.current_signal_dbm}dBm)")
|
||||||
|
self.roaming = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def log_coverage(self):
|
||||||
|
try:
|
||||||
|
with open(self.log_file, 'a') as f:
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
f.write(f"{timestamp} | SSID:{self.current_ssid} | Signal:{self.current_signal_dbm}dBm | Connected:{self.connected} | Roaming:{self.roaming}\n")
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().debug(f"Log error: {e}")
|
||||||
|
|
||||||
|
def publish_state(self):
|
||||||
|
msg = String()
|
||||||
|
msg.data = f"ssid:{self.current_ssid}|signal:{self.current_signal_dbm}dBm|connected:{self.connected}|roaming:{self.roaming}|tether:{self.tether_active}"
|
||||||
|
self.wifi_state_pub.publish(msg)
|
||||||
|
|
||||||
|
def cmd_callback(self, msg):
|
||||||
|
if msg.data == "rescan":
|
||||||
|
self.get_logger().info("WiFi rescan requested")
|
||||||
|
self.scan_aps()
|
||||||
|
elif msg.data == "enable_tether":
|
||||||
|
self.activate_tether()
|
||||||
|
elif msg.data == "disable_tether":
|
||||||
|
self.deactivate_tether()
|
||||||
|
|
||||||
|
def monitor_loop(self):
|
||||||
|
status = self.get_wifi_status()
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
prev_ssid = self.current_ssid
|
||||||
|
prev_signal = self.current_signal_dbm
|
||||||
|
prev_connected = self.connected
|
||||||
|
|
||||||
|
self.current_ssid = status['ssid']
|
||||||
|
self.current_signal_dbm = status['signal_dbm']
|
||||||
|
self.connected = status['connected']
|
||||||
|
|
||||||
|
if self.current_ssid != prev_ssid:
|
||||||
|
self.get_logger().info(f"AP change: {prev_ssid} → {self.current_ssid}")
|
||||||
|
self.log_coverage()
|
||||||
|
|
||||||
|
if abs(self.current_signal_dbm - prev_signal) >= 5:
|
||||||
|
self.log_coverage()
|
||||||
|
|
||||||
|
if self.gateway_ping():
|
||||||
|
self.last_gateway_ping = time.time()
|
||||||
|
self.offline_start = None
|
||||||
|
if self.tether_active:
|
||||||
|
self.deactivate_tether()
|
||||||
|
else:
|
||||||
|
if self.offline_start is None:
|
||||||
|
self.offline_start = time.time()
|
||||||
|
offline_time = time.time() - self.offline_start
|
||||||
|
if offline_time > self.offline_timeout:
|
||||||
|
warning = String()
|
||||||
|
warning.data = "Warning: Lost WiFi connectivity. Activating USB tethering."
|
||||||
|
self.warning_pub.publish(warning)
|
||||||
|
self.activate_tether()
|
||||||
|
|
||||||
|
self.check_roaming_needed()
|
||||||
|
self.publish_state()
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = WiFiMonitorNode()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
5
jetson/ros2_ws/src/saltybot_wifi_monitor/setup.cfg
Normal file
5
jetson/ros2_ws/src/saltybot_wifi_monitor/setup.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[develop]
|
||||||
|
script-dir=$base/lib/saltybot_wifi_monitor
|
||||||
|
[egg_info]
|
||||||
|
tag_build =
|
||||||
|
tag_date = 0
|
||||||
27
jetson/ros2_ws/src/saltybot_wifi_monitor/setup.py
Normal file
27
jetson/ros2_ws/src/saltybot_wifi_monitor/setup.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
package_name = 'saltybot_wifi_monitor'
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=[package_name],
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
|
||||||
|
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='seb',
|
||||||
|
maintainer_email='seb@vayrette.com',
|
||||||
|
description='WiFi mesh handoff + USB tethering fallback',
|
||||||
|
license='MIT',
|
||||||
|
tests_require=['pytest'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'wifi_monitor = saltybot_wifi_monitor.wifi_monitor_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
14
jetson/ros2_ws/src/saltybot_wifi_msgs/CMakeLists.txt
Normal file
14
jetson/ros2_ws/src/saltybot_wifi_msgs/CMakeLists.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.8)
|
||||||
|
project(saltybot_wifi_msgs)
|
||||||
|
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||||
|
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
find_package(ament_cmake REQUIRED)
|
||||||
|
find_package(std_msgs REQUIRED)
|
||||||
|
find_package(builtin_interfaces REQUIRED)
|
||||||
|
find_package(rosidl_default_generators REQUIRED)
|
||||||
|
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||||
|
"msg/WiFiState.msg"
|
||||||
|
DEPENDENCIES std_msgs builtin_interfaces
|
||||||
|
)
|
||||||
|
ament_package()
|
||||||
13
jetson/ros2_ws/src/saltybot_wifi_msgs/msg/WiFiState.msg
Normal file
13
jetson/ros2_ws/src/saltybot_wifi_msgs/msg/WiFiState.msg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
std_msgs/Header header
|
||||||
|
string ssid
|
||||||
|
int32 signal_dbm
|
||||||
|
bool connected
|
||||||
|
uint32 latency_ms
|
||||||
|
string ap_addr
|
||||||
|
uint32 frequency_mhz
|
||||||
|
uint32 link_quality
|
||||||
|
uint32 tx_rate_mbps
|
||||||
|
uint32 rx_rate_mbps
|
||||||
|
bool roaming
|
||||||
|
string[] available_aps
|
||||||
|
int32[] available_signal_dbm
|
||||||
16
jetson/ros2_ws/src/saltybot_wifi_msgs/package.xml
Normal file
16
jetson/ros2_ws/src/saltybot_wifi_msgs/package.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?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_wifi_msgs</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>ROS2 message definitions for WiFi monitoring and mesh handoff.</description>
|
||||||
|
<maintainer email="seb@vayrette.com">seb</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>builtin_interfaces</depend>
|
||||||
|
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_cmake</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
Loading…
x
Reference in New Issue
Block a user