Initial workspace — six-package autonomy stack
This commit is contained in:
commit
9b03548533
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# ROS2 build artifacts
|
||||||
|
build/
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ROV Autonomy Stack — ARM64 Container Image
|
||||||
|
# Target: Raspberry Pi 5 running Ubuntu 24.04 LTS
|
||||||
|
# Base: ROS2 Jazzy on Ubuntu 24.04
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
FROM ros:jazzy-ros-base-noble
|
||||||
|
|
||||||
|
# Set non-interactive apt installs
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install ROS2 dependencies and tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
# ROS2 packages required by the stack
|
||||||
|
ros-jazzy-mavros \
|
||||||
|
ros-jazzy-mavros-extras \
|
||||||
|
ros-jazzy-robot-localization \
|
||||||
|
ros-jazzy-cv-bridge \
|
||||||
|
ros-jazzy-image-transport \
|
||||||
|
# Python dependencies
|
||||||
|
python3-pip \
|
||||||
|
python3-opencv \
|
||||||
|
# Build tools
|
||||||
|
python3-colcon-common-extensions \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install geographic datasets required by MAVROS
|
||||||
|
RUN /opt/ros/jazzy/lib/mavros/install_geographiclib_datasets.sh
|
||||||
|
|
||||||
|
# Create workspace directory
|
||||||
|
WORKDIR /ros2_ws
|
||||||
|
|
||||||
|
# Copy workspace source into image
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
# Build the workspace
|
||||||
|
RUN /bin/bash -c "source /opt/ros/jazzy/setup.bash && \
|
||||||
|
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release"
|
||||||
|
|
||||||
|
# Copy and configure entrypoint
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["ros2", "launch", "rov_bringup", "rov_full.launch.py"]
|
||||||
46
README.md
Normal file
46
README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# ROV Autonomy Stack
|
||||||
|
|
||||||
|
ROS2 Jazzy workspace for the BlueROV2 Heavy autonomous inspection system.
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `rov_interfaces` | Custom messages and services |
|
||||||
|
| `rov_navigation` | State estimation, EKF, depth |
|
||||||
|
| `rov_perception` | Camera nodes, feature detection |
|
||||||
|
| `rov_control` | Motion controller, failsafe monitor |
|
||||||
|
| `rov_mission` | Mission executor, waypoint sequencing |
|
||||||
|
| `rov_bringup` | Top-level launch files |
|
||||||
|
|
||||||
|
## Quick Start (on RPi5)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull image from Harbor
|
||||||
|
docker pull registry.symbytech.com/rov-autonomy/rov-stack:latest
|
||||||
|
|
||||||
|
# Run full stack (dev environment)
|
||||||
|
docker run --rm --network host \
|
||||||
|
registry.symbytech.com/rov-autonomy/rov-stack:latest \
|
||||||
|
ros2 launch rov_bringup rov_full.launch.py env:=dev blueos_ip:=<RPi4_IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building Locally (on server with buildx)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 \
|
||||||
|
-t registry.symbytech.com/rov-autonomy/rov-stack:dev \
|
||||||
|
--push .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Topic Reference
|
||||||
|
|
||||||
|
| Topic | Type | Publisher | Subscribers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/imu/data` | sensor_msgs/Imu | Xsens driver | state_estimator |
|
||||||
|
| `/mavros/imu/data` | sensor_msgs/Imu | MAVROS | state_estimator |
|
||||||
|
| `/rov/state` | nav_msgs/Odometry | state_estimator | motion_controller, mission_executor |
|
||||||
|
| `/rov/cmd_vel` | geometry_msgs/Twist | mission_executor | motion_controller |
|
||||||
|
| `/rov/failsafe` | rov_interfaces/FailsafeStatus | failsafe_monitor | motion_controller, mission_executor |
|
||||||
|
| `/rov/mission/status` | rov_interfaces/MissionStatus | mission_executor | web UI |
|
||||||
|
| `/camera/front/image_raw` | sensor_msgs/Image | camera_node | feature_detector |
|
||||||
13
docker-entrypoint.sh
Normal file
13
docker-entrypoint.sh
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Source ROS2 and workspace, then execute the provided command
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Source ROS2 base
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
|
||||||
|
# Source the built workspace
|
||||||
|
source /ros2_ws/install/setup.bash
|
||||||
|
|
||||||
|
# Execute the command passed to the container
|
||||||
|
exec "$@"
|
||||||
5
src/rov_bringup/CMakeLists.txt
Normal file
5
src/rov_bringup/CMakeLists.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.8)
|
||||||
|
project(rov_bringup)
|
||||||
|
find_package(ament_cmake REQUIRED)
|
||||||
|
install(DIRECTORY launch config DESTINATION share/${PROJECT_NAME})
|
||||||
|
ament_package()
|
||||||
37
src/rov_bringup/launch/rov_dev.launch.py
Normal file
37
src/rov_bringup/launch/rov_dev.launch.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Development bringup — minimal stack for desk testing.
|
||||||
|
No cameras. MAVROS pointed at simulator or bench RPi4.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
return LaunchDescription([
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
'blueos_ip', default_value='192.168.1.x',
|
||||||
|
description='IP address of BlueOS'
|
||||||
|
),
|
||||||
|
|
||||||
|
# MAVROS only
|
||||||
|
Node(
|
||||||
|
package='mavros',
|
||||||
|
executable='mavros_node',
|
||||||
|
name='mavros',
|
||||||
|
output='screen',
|
||||||
|
parameters=[{
|
||||||
|
'fcu_url': ['udp://', LaunchConfiguration('blueos_ip'), ':14550@14555'],
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
|
||||||
|
# Failsafe monitor — always required
|
||||||
|
Node(
|
||||||
|
package='rov_control',
|
||||||
|
executable='failsafe_monitor',
|
||||||
|
name='failsafe_monitor',
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
])
|
||||||
82
src/rov_bringup/launch/rov_full.launch.py
Normal file
82
src/rov_bringup/launch/rov_full.launch.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Full ROV autonomy stack bringup.
|
||||||
|
Launches all subsystems: MAVROS, navigation, control, perception, mission.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ros2 launch rov_bringup rov_full.launch.py env:=dev
|
||||||
|
ros2 launch rov_bringup rov_full.launch.py env:=field
|
||||||
|
"""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
|
||||||
|
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
# Package directories
|
||||||
|
nav_pkg = get_package_share_directory('rov_navigation')
|
||||||
|
ctrl_pkg = get_package_share_directory('rov_control')
|
||||||
|
perc_pkg = get_package_share_directory('rov_perception')
|
||||||
|
mission_pkg = get_package_share_directory('rov_mission')
|
||||||
|
bringup_pkg = get_package_share_directory('rov_bringup')
|
||||||
|
|
||||||
|
env_arg = DeclareLaunchArgument(
|
||||||
|
'env', default_value='dev',
|
||||||
|
description='Deployment environment: dev, pool, field'
|
||||||
|
)
|
||||||
|
|
||||||
|
blueos_ip_arg = DeclareLaunchArgument(
|
||||||
|
'blueos_ip', default_value='192.168.1.x',
|
||||||
|
description='IP address of BlueOS (RPi4)'
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
env_arg,
|
||||||
|
blueos_ip_arg,
|
||||||
|
|
||||||
|
# MAVROS — bridge to ArduSub on RPi4
|
||||||
|
Node(
|
||||||
|
package='mavros',
|
||||||
|
executable='mavros_node',
|
||||||
|
name='mavros',
|
||||||
|
output='screen',
|
||||||
|
parameters=[{
|
||||||
|
'fcu_url': ['udp://', LaunchConfiguration('blueos_ip'), ':14550@14555'],
|
||||||
|
'gcs_url': '',
|
||||||
|
'target_system_id': 1,
|
||||||
|
'target_component_id': 1,
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
|
||||||
|
# Navigation stack
|
||||||
|
IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
os.path.join(nav_pkg, 'launch', 'navigation.launch.py')
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Control stack (failsafe first, then motion)
|
||||||
|
IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
os.path.join(ctrl_pkg, 'launch', 'control.launch.py')
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Perception stack
|
||||||
|
IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
os.path.join(perc_pkg, 'launch', 'perception.launch.py')
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Mission executor
|
||||||
|
IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
os.path.join(mission_pkg, 'launch', 'mission.launch.py')
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
17
src/rov_bringup/package.xml
Normal file
17
src/rov_bringup/package.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_bringup</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Top-level bringup launch files for full ROV autonomy stack</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
<depend>rov_navigation</depend>
|
||||||
|
<depend>rov_perception</depend>
|
||||||
|
<depend>rov_control</depend>
|
||||||
|
<depend>rov_mission</depend>
|
||||||
|
<depend>mavros</depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_cmake</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
8
src/rov_control/config/failsafe.yaml
Normal file
8
src/rov_control/config/failsafe.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Failsafe configuration
|
||||||
|
# Tune thresholds before any pool or field testing
|
||||||
|
|
||||||
|
failsafe_monitor:
|
||||||
|
ros__parameters:
|
||||||
|
low_battery_threshold: 0.20 # Surface when battery below 20%
|
||||||
|
max_depth_m: 50.0 # Emergency surface if depth exceeded
|
||||||
|
comms_timeout_s: 5.0 # Surface after 5s comms loss
|
||||||
29
src/rov_control/launch/control.launch.py
Normal file
29
src/rov_control/launch/control.launch.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Control stack launch file."""
|
||||||
|
|
||||||
|
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():
|
||||||
|
pkg_dir = get_package_share_directory('rov_control')
|
||||||
|
failsafe_config = os.path.join(pkg_dir, 'config', 'failsafe.yaml')
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
# Failsafe monitor — always start first
|
||||||
|
Node(
|
||||||
|
package='rov_control',
|
||||||
|
executable='failsafe_monitor',
|
||||||
|
name='failsafe_monitor',
|
||||||
|
output='screen',
|
||||||
|
parameters=[failsafe_config],
|
||||||
|
),
|
||||||
|
# Motion controller
|
||||||
|
Node(
|
||||||
|
package='rov_control',
|
||||||
|
executable='motion_controller',
|
||||||
|
name='motion_controller',
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
])
|
||||||
17
src/rov_control/package.xml
Normal file
17
src/rov_control/package.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_control</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Motion control, depth hold, and thruster management</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>mavros_msgs</depend>
|
||||||
|
<depend>rov_interfaces</depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
src/rov_control/resource/rov_control
Normal file
0
src/rov_control/resource/rov_control
Normal file
0
src/rov_control/rov_control/__init__.py
Normal file
0
src/rov_control/rov_control/__init__.py
Normal file
135
src/rov_control/rov_control/failsafe_monitor.py
Normal file
135
src/rov_control/rov_control/failsafe_monitor.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Failsafe monitor node.
|
||||||
|
Monitors all safety-critical conditions and publishes failsafe state.
|
||||||
|
This node must be running at all times during autonomous operation.
|
||||||
|
|
||||||
|
Conditions monitored:
|
||||||
|
- Comms loss (heartbeat timeout)
|
||||||
|
- Low battery (from MAVROS battery state)
|
||||||
|
- Maximum depth exceeded
|
||||||
|
- Obstacle detection (future — proximity sensors)
|
||||||
|
- Manual abort command
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/mavros/battery (sensor_msgs/BatteryState) — battery level
|
||||||
|
/mavros/state (mavros_msgs/State) — FCU connection state
|
||||||
|
/rov/mission/abort (std_msgs/Bool) — manual abort trigger
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/rov/failsafe (rov_interfaces/FailsafeStatus) — current failsafe state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.duration import Duration
|
||||||
|
from std_msgs.msg import Bool
|
||||||
|
from sensor_msgs.msg import BatteryState
|
||||||
|
from mavros_msgs.msg import State
|
||||||
|
from rov_interfaces.msg import FailsafeStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FailsafeMonitor(Node):
|
||||||
|
"""Monitors safety conditions and publishes failsafe status."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('failsafe_monitor')
|
||||||
|
|
||||||
|
# --- Parameters ---
|
||||||
|
self.declare_parameter('low_battery_threshold', 0.20) # 20% remaining
|
||||||
|
self.declare_parameter('max_depth_m', 50.0) # metres
|
||||||
|
self.declare_parameter('comms_timeout_s', 5.0) # seconds
|
||||||
|
|
||||||
|
self.low_batt_thresh = self.get_parameter('low_battery_threshold').value
|
||||||
|
self.max_depth = self.get_parameter('max_depth_m').value
|
||||||
|
self.comms_timeout = self.get_parameter('comms_timeout_s').value
|
||||||
|
|
||||||
|
# Internal state
|
||||||
|
self.last_heartbeat = self.get_clock().now()
|
||||||
|
self.battery_percent = 1.0
|
||||||
|
self.fcu_connected = False
|
||||||
|
|
||||||
|
# --- Subscribers ---
|
||||||
|
self.battery_sub = self.create_subscription(
|
||||||
|
BatteryState, '/mavros/battery', self.battery_callback, 10)
|
||||||
|
self.state_sub = self.create_subscription(
|
||||||
|
State, '/mavros/state', self.state_callback, 10)
|
||||||
|
self.abort_sub = self.create_subscription(
|
||||||
|
Bool, '/rov/mission/abort', self.abort_callback, 10)
|
||||||
|
|
||||||
|
# --- Publishers ---
|
||||||
|
self.failsafe_pub = self.create_publisher(FailsafeStatus, '/rov/failsafe', 10)
|
||||||
|
|
||||||
|
# --- Timer: evaluate failsafe conditions at 10Hz ---
|
||||||
|
self.timer = self.create_timer(0.1, self.evaluate_failsafe)
|
||||||
|
|
||||||
|
self.get_logger().info('FailsafeMonitor node started')
|
||||||
|
|
||||||
|
def battery_callback(self, msg: BatteryState):
|
||||||
|
"""Update battery state."""
|
||||||
|
self.battery_percent = msg.percentage
|
||||||
|
|
||||||
|
def state_callback(self, msg: State):
|
||||||
|
"""Update FCU connection state and heartbeat time."""
|
||||||
|
self.fcu_connected = msg.connected
|
||||||
|
if msg.connected:
|
||||||
|
self.last_heartbeat = self.get_clock().now()
|
||||||
|
|
||||||
|
def abort_callback(self, msg: Bool):
|
||||||
|
"""Handle manual abort command."""
|
||||||
|
if msg.data:
|
||||||
|
self.get_logger().warn('Manual abort received')
|
||||||
|
self.publish_failsafe(
|
||||||
|
manual_abort=True,
|
||||||
|
action=FailsafeStatus.ACTION_SURFACE,
|
||||||
|
message='Manual abort commanded'
|
||||||
|
)
|
||||||
|
|
||||||
|
def evaluate_failsafe(self):
|
||||||
|
"""Evaluate all failsafe conditions and publish status."""
|
||||||
|
now = self.get_clock().now()
|
||||||
|
elapsed = (now - self.last_heartbeat).nanoseconds / 1e9
|
||||||
|
|
||||||
|
comms_loss = elapsed > self.comms_timeout
|
||||||
|
low_battery = self.battery_percent < self.low_batt_thresh
|
||||||
|
|
||||||
|
if comms_loss:
|
||||||
|
self.publish_failsafe(
|
||||||
|
comms_loss=True,
|
||||||
|
action=FailsafeStatus.ACTION_SURFACE,
|
||||||
|
message=f'Comms lost for {elapsed:.1f}s'
|
||||||
|
)
|
||||||
|
elif low_battery:
|
||||||
|
self.publish_failsafe(
|
||||||
|
low_battery=True,
|
||||||
|
action=FailsafeStatus.ACTION_SURFACE,
|
||||||
|
message=f'Low battery: {self.battery_percent*100:.0f}%'
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish_failsafe(self, comms_loss=False, low_battery=False,
|
||||||
|
depth_exceeded=False, obstacle_detected=False,
|
||||||
|
manual_abort=False,
|
||||||
|
action=FailsafeStatus.ACTION_NONE, message=''):
|
||||||
|
"""Publish a failsafe status message."""
|
||||||
|
msg = FailsafeStatus()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.comms_loss = comms_loss
|
||||||
|
msg.low_battery = low_battery
|
||||||
|
msg.depth_exceeded = depth_exceeded
|
||||||
|
msg.obstacle_detected = obstacle_detected
|
||||||
|
msg.manual_abort = manual_abort
|
||||||
|
msg.action = action
|
||||||
|
msg.message = message
|
||||||
|
self.failsafe_pub.publish(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = FailsafeMonitor()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
91
src/rov_control/rov_control/motion_controller.py
Normal file
91
src/rov_control/rov_control/motion_controller.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Motion controller node.
|
||||||
|
Translates high-level velocity setpoints into MAVROS commands.
|
||||||
|
Implements depth hold and heading hold modes.
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/rov/cmd_vel (geometry_msgs/Twist) — velocity setpoint from mission planner
|
||||||
|
/rov/state (nav_msgs/Odometry) — current state from navigation stack
|
||||||
|
/rov/failsafe (rov_interfaces/FailsafeStatus) — emergency override
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/mavros/setpoint_velocity/cmd_vel (geometry_msgs/TwistStamped) — to ArduSub
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from geometry_msgs.msg import Twist, TwistStamped
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from rov_interfaces.msg import FailsafeStatus
|
||||||
|
|
||||||
|
|
||||||
|
class MotionController(Node):
|
||||||
|
"""Translates velocity setpoints to MAVROS commands."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('motion_controller')
|
||||||
|
|
||||||
|
# --- Parameters ---
|
||||||
|
self.declare_parameter('max_surge_ms', 0.5) # m/s forward
|
||||||
|
self.declare_parameter('max_heave_ms', 0.3) # m/s vertical
|
||||||
|
self.declare_parameter('max_yaw_rads', 0.3) # rad/s yaw rate
|
||||||
|
|
||||||
|
self.max_surge = self.get_parameter('max_surge_ms').value
|
||||||
|
self.max_heave = self.get_parameter('max_heave_ms').value
|
||||||
|
self.max_yaw = self.get_parameter('max_yaw_rads').value
|
||||||
|
|
||||||
|
self.failsafe_active = False
|
||||||
|
|
||||||
|
# --- Subscribers ---
|
||||||
|
self.cmd_sub = self.create_subscription(
|
||||||
|
Twist, '/rov/cmd_vel', self.cmd_callback, 10)
|
||||||
|
self.state_sub = self.create_subscription(
|
||||||
|
Odometry, '/rov/state', self.state_callback, 10)
|
||||||
|
self.failsafe_sub = self.create_subscription(
|
||||||
|
FailsafeStatus, '/rov/failsafe', self.failsafe_callback, 10)
|
||||||
|
|
||||||
|
# --- Publishers ---
|
||||||
|
self.mavros_pub = self.create_publisher(
|
||||||
|
TwistStamped, '/mavros/setpoint_velocity/cmd_vel', 10)
|
||||||
|
|
||||||
|
self.get_logger().info('MotionController node started')
|
||||||
|
|
||||||
|
def cmd_callback(self, msg: Twist):
|
||||||
|
"""Translate velocity setpoint to MAVROS command."""
|
||||||
|
if self.failsafe_active:
|
||||||
|
return # Failsafe overrides all motion commands
|
||||||
|
|
||||||
|
out = TwistStamped()
|
||||||
|
out.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
out.header.frame_id = 'base_link'
|
||||||
|
|
||||||
|
# Clamp velocities to safe limits
|
||||||
|
out.twist.linear.x = max(-self.max_surge, min(self.max_surge, msg.linear.x))
|
||||||
|
out.twist.linear.z = max(-self.max_heave, min(self.max_heave, msg.linear.z))
|
||||||
|
out.twist.angular.z = max(-self.max_yaw, min(self.max_yaw, msg.angular.z))
|
||||||
|
|
||||||
|
self.mavros_pub.publish(out)
|
||||||
|
|
||||||
|
def state_callback(self, msg: Odometry):
|
||||||
|
"""Update internal state estimate."""
|
||||||
|
# TODO: use for depth hold and heading hold PID
|
||||||
|
pass
|
||||||
|
|
||||||
|
def failsafe_callback(self, msg: FailsafeStatus):
|
||||||
|
"""Handle failsafe state changes."""
|
||||||
|
self.failsafe_active = (msg.action != FailsafeStatus.ACTION_NONE)
|
||||||
|
if self.failsafe_active:
|
||||||
|
self.get_logger().warn(f'Failsafe active: {msg.message}')
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = MotionController()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
25
src/rov_control/setup.py
Normal file
25
src/rov_control/setup.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'rov_control'
|
||||||
|
|
||||||
|
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,
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'motion_controller = rov_control.motion_controller:main',
|
||||||
|
'failsafe_monitor = rov_control.failsafe_monitor:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
16
src/rov_interfaces/CMakeLists.txt
Normal file
16
src/rov_interfaces/CMakeLists.txt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.8)
|
||||||
|
project(rov_interfaces)
|
||||||
|
find_package(ament_cmake REQUIRED)
|
||||||
|
find_package(rosidl_default_generators REQUIRED)
|
||||||
|
find_package(std_msgs REQUIRED)
|
||||||
|
find_package(geometry_msgs REQUIRED)
|
||||||
|
|
||||||
|
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||||
|
"msg/MissionStatus.msg"
|
||||||
|
"msg/FailsafeStatus.msg"
|
||||||
|
"msg/InspectionWaypoint.msg"
|
||||||
|
"srv/MissionCommand.srv"
|
||||||
|
DEPENDENCIES std_msgs geometry_msgs
|
||||||
|
)
|
||||||
|
ament_export_dependencies(rosidl_default_runtime)
|
||||||
|
ament_package()
|
||||||
12
src/rov_interfaces/msg/FailsafeStatus.msg
Normal file
12
src/rov_interfaces/msg/FailsafeStatus.msg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
std_msgs/Header header
|
||||||
|
bool comms_loss
|
||||||
|
bool low_battery
|
||||||
|
bool depth_exceeded
|
||||||
|
bool obstacle_detected
|
||||||
|
bool manual_abort
|
||||||
|
uint8 action
|
||||||
|
uint8 ACTION_NONE=0
|
||||||
|
uint8 ACTION_HOVER=1
|
||||||
|
uint8 ACTION_SURFACE=2
|
||||||
|
uint8 ACTION_EMERGENCY_SURFACE=3
|
||||||
|
string message
|
||||||
8
src/rov_interfaces/msg/InspectionWaypoint.msg
Normal file
8
src/rov_interfaces/msg/InspectionWaypoint.msg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
std_msgs/Header header
|
||||||
|
string waypoint_id
|
||||||
|
geometry_msgs/Point position
|
||||||
|
float64 heading_deg
|
||||||
|
float64 standoff_m
|
||||||
|
float64 loiter_time_s
|
||||||
|
bool capture_image
|
||||||
|
string panel_id
|
||||||
11
src/rov_interfaces/msg/MissionStatus.msg
Normal file
11
src/rov_interfaces/msg/MissionStatus.msg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
std_msgs/Header header
|
||||||
|
string mission_id
|
||||||
|
uint8 state
|
||||||
|
uint8 STATE_IDLE=0
|
||||||
|
uint8 STATE_RUNNING=1
|
||||||
|
uint8 STATE_PAUSED=2
|
||||||
|
uint8 STATE_COMPLETE=3
|
||||||
|
uint8 STATE_ABORTED=4
|
||||||
|
float32 progress_percent
|
||||||
|
string current_task
|
||||||
|
string message
|
||||||
17
src/rov_interfaces/package.xml
Normal file
17
src/rov_interfaces/package.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_interfaces</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Custom ROS2 messages and services for ROV autonomy stack</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>rosidl_default_generators</depend>
|
||||||
|
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||||
|
<exec_depend>rosidl_default_runtime</exec_depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_cmake</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
6
src/rov_interfaces/srv/MissionCommand.srv
Normal file
6
src/rov_interfaces/srv/MissionCommand.srv
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
string command
|
||||||
|
string mission_id
|
||||||
|
string[] parameters
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
string message
|
||||||
8
src/rov_mission/config/dev.yaml
Normal file
8
src/rov_mission/config/dev.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Development configuration
|
||||||
|
# Looser tolerances for desk/pool testing
|
||||||
|
|
||||||
|
mission_executor:
|
||||||
|
ros__parameters:
|
||||||
|
waypoint_arrival_radius_m: 0.5 # metres — looser for dev
|
||||||
|
waypoint_timeout_s: 60.0
|
||||||
|
image_capture_delay_s: 1.0
|
||||||
8
src/rov_mission/config/field.yaml
Normal file
8
src/rov_mission/config/field.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Field deployment configuration
|
||||||
|
# Used when deploying to actual inspection site
|
||||||
|
|
||||||
|
mission_executor:
|
||||||
|
ros__parameters:
|
||||||
|
waypoint_arrival_radius_m: 0.3 # metres — tighter in field
|
||||||
|
waypoint_timeout_s: 120.0 # seconds per waypoint before abort
|
||||||
|
image_capture_delay_s: 2.0 # settle time before capture
|
||||||
30
src/rov_mission/launch/mission.launch.py
Normal file
30
src/rov_mission/launch/mission.launch.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Mission executor launch file."""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
pkg_dir = get_package_share_directory('rov_mission')
|
||||||
|
|
||||||
|
env_arg = DeclareLaunchArgument(
|
||||||
|
'env', default_value='dev',
|
||||||
|
description='Deployment environment: dev, field'
|
||||||
|
)
|
||||||
|
|
||||||
|
env = LaunchConfiguration('env')
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
env_arg,
|
||||||
|
Node(
|
||||||
|
package='rov_mission',
|
||||||
|
executable='mission_executor',
|
||||||
|
name='mission_executor',
|
||||||
|
output='screen',
|
||||||
|
# Config selected by env argument at launch time
|
||||||
|
),
|
||||||
|
])
|
||||||
16
src/rov_mission/package.xml
Normal file
16
src/rov_mission/package.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_mission</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Mission planning, waypoint execution, and inspection sequencing</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>rov_interfaces</depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
src/rov_mission/resource/rov_mission
Normal file
0
src/rov_mission/resource/rov_mission
Normal file
0
src/rov_mission/rov_mission/__init__.py
Normal file
0
src/rov_mission/rov_mission/__init__.py
Normal file
183
src/rov_mission/rov_mission/mission_executor.py
Normal file
183
src/rov_mission/rov_mission/mission_executor.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mission executor node.
|
||||||
|
Manages inspection mission state machine.
|
||||||
|
Sequences waypoints, triggers image capture, handles replanning.
|
||||||
|
|
||||||
|
States: IDLE → RUNNING → (PAUSED | COMPLETE | ABORTED)
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/rov/state (nav_msgs/Odometry) — current position
|
||||||
|
/rov/failsafe (rov_interfaces/FailsafeStatus) — safety override
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/rov/cmd_vel (geometry_msgs/Twist) — velocity setpoints to controller
|
||||||
|
/rov/mission/status (rov_interfaces/MissionStatus) — current mission state
|
||||||
|
|
||||||
|
Services provided:
|
||||||
|
/rov/mission/command (rov_interfaces/MissionCommand) — start/pause/abort/resume
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from geometry_msgs.msg import Twist
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from rov_interfaces.msg import MissionStatus, FailsafeStatus, InspectionWaypoint
|
||||||
|
from rov_interfaces.srv import MissionCommand
|
||||||
|
|
||||||
|
|
||||||
|
class MissionExecutor(Node):
|
||||||
|
"""Executes inspection missions from a waypoint list."""
|
||||||
|
|
||||||
|
# Mission states
|
||||||
|
STATE_IDLE = 'IDLE'
|
||||||
|
STATE_RUNNING = 'RUNNING'
|
||||||
|
STATE_PAUSED = 'PAUSED'
|
||||||
|
STATE_COMPLETE = 'COMPLETE'
|
||||||
|
STATE_ABORTED = 'ABORTED'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('mission_executor')
|
||||||
|
|
||||||
|
self.state = self.STATE_IDLE
|
||||||
|
self.waypoints = [] # List of InspectionWaypoint
|
||||||
|
self.current_wp_index = 0
|
||||||
|
self.mission_id = ''
|
||||||
|
self.current_position = None
|
||||||
|
self.failsafe_active = False
|
||||||
|
|
||||||
|
# --- Subscribers ---
|
||||||
|
self.state_sub = self.create_subscription(
|
||||||
|
Odometry, '/rov/state', self.state_callback, 10)
|
||||||
|
self.failsafe_sub = self.create_subscription(
|
||||||
|
FailsafeStatus, '/rov/failsafe', self.failsafe_callback, 10)
|
||||||
|
|
||||||
|
# --- Publishers ---
|
||||||
|
self.cmd_pub = self.create_publisher(Twist, '/rov/cmd_vel', 10)
|
||||||
|
self.status_pub = self.create_publisher(MissionStatus, '/rov/mission/status', 10)
|
||||||
|
|
||||||
|
# --- Services ---
|
||||||
|
self.cmd_srv = self.create_service(
|
||||||
|
MissionCommand, '/rov/mission/command', self.command_callback)
|
||||||
|
|
||||||
|
# --- Timer: mission loop at 10Hz ---
|
||||||
|
self.timer = self.create_timer(0.1, self.mission_loop)
|
||||||
|
|
||||||
|
self.get_logger().info('MissionExecutor node started')
|
||||||
|
|
||||||
|
def state_callback(self, msg: Odometry):
|
||||||
|
"""Update current position."""
|
||||||
|
self.current_position = msg.pose.pose.position
|
||||||
|
|
||||||
|
def failsafe_callback(self, msg: FailsafeStatus):
|
||||||
|
"""Handle failsafe — abort mission if active."""
|
||||||
|
was_active = self.failsafe_active
|
||||||
|
self.failsafe_active = (msg.action != FailsafeStatus.ACTION_NONE)
|
||||||
|
if self.failsafe_active and not was_active:
|
||||||
|
self.get_logger().warn('Failsafe triggered — aborting mission')
|
||||||
|
self.state = self.STATE_ABORTED
|
||||||
|
|
||||||
|
def command_callback(self, request, response):
|
||||||
|
"""Handle mission command service calls."""
|
||||||
|
cmd = request.command.upper()
|
||||||
|
|
||||||
|
if cmd == 'START':
|
||||||
|
if self.state == self.STATE_IDLE:
|
||||||
|
self.mission_id = request.mission_id
|
||||||
|
self.current_wp_index = 0
|
||||||
|
self.state = self.STATE_RUNNING
|
||||||
|
response.success = True
|
||||||
|
response.message = f'Mission {self.mission_id} started'
|
||||||
|
else:
|
||||||
|
response.success = False
|
||||||
|
response.message = f'Cannot start — current state: {self.state}'
|
||||||
|
|
||||||
|
elif cmd == 'PAUSE':
|
||||||
|
if self.state == self.STATE_RUNNING:
|
||||||
|
self.state = self.STATE_PAUSED
|
||||||
|
response.success = True
|
||||||
|
response.message = 'Mission paused'
|
||||||
|
else:
|
||||||
|
response.success = False
|
||||||
|
response.message = f'Cannot pause — current state: {self.state}'
|
||||||
|
|
||||||
|
elif cmd == 'RESUME':
|
||||||
|
if self.state == self.STATE_PAUSED:
|
||||||
|
self.state = self.STATE_RUNNING
|
||||||
|
response.success = True
|
||||||
|
response.message = 'Mission resumed'
|
||||||
|
else:
|
||||||
|
response.success = False
|
||||||
|
response.message = f'Cannot resume — current state: {self.state}'
|
||||||
|
|
||||||
|
elif cmd == 'ABORT':
|
||||||
|
self.state = self.STATE_ABORTED
|
||||||
|
self.stop_motion()
|
||||||
|
response.success = True
|
||||||
|
response.message = 'Mission aborted'
|
||||||
|
|
||||||
|
else:
|
||||||
|
response.success = False
|
||||||
|
response.message = f'Unknown command: {cmd}'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def mission_loop(self):
|
||||||
|
"""Main mission execution loop — called at 10Hz."""
|
||||||
|
self.publish_status()
|
||||||
|
|
||||||
|
if self.state != self.STATE_RUNNING:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.waypoints:
|
||||||
|
self.get_logger().info('No waypoints — mission complete')
|
||||||
|
self.state = self.STATE_COMPLETE
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_wp_index >= len(self.waypoints):
|
||||||
|
self.get_logger().info('All waypoints reached — mission complete')
|
||||||
|
self.state = self.STATE_COMPLETE
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: navigate to current waypoint
|
||||||
|
# TODO: check arrival condition
|
||||||
|
# TODO: trigger image capture if required
|
||||||
|
# TODO: advance to next waypoint on arrival
|
||||||
|
|
||||||
|
def stop_motion(self):
|
||||||
|
"""Publish zero velocity to stop the ROV."""
|
||||||
|
self.cmd_pub.publish(Twist()) # All zeros = stop
|
||||||
|
|
||||||
|
def publish_status(self):
|
||||||
|
"""Publish current mission status."""
|
||||||
|
msg = MissionStatus()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.mission_id = self.mission_id
|
||||||
|
msg.current_task = f'Waypoint {self.current_wp_index}/{len(self.waypoints)}'
|
||||||
|
msg.message = self.state
|
||||||
|
|
||||||
|
if len(self.waypoints) > 0:
|
||||||
|
msg.progress_percent = (self.current_wp_index / len(self.waypoints)) * 100.0
|
||||||
|
|
||||||
|
# Map state string to uint8
|
||||||
|
state_map = {
|
||||||
|
self.STATE_IDLE: MissionStatus.STATE_IDLE,
|
||||||
|
self.STATE_RUNNING: MissionStatus.STATE_RUNNING,
|
||||||
|
self.STATE_PAUSED: MissionStatus.STATE_PAUSED,
|
||||||
|
self.STATE_COMPLETE: MissionStatus.STATE_COMPLETE,
|
||||||
|
self.STATE_ABORTED: MissionStatus.STATE_ABORTED,
|
||||||
|
}
|
||||||
|
msg.state = state_map.get(self.state, MissionStatus.STATE_IDLE)
|
||||||
|
self.status_pub.publish(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = MissionExecutor()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
24
src/rov_mission/setup.py
Normal file
24
src/rov_mission/setup.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'rov_mission'
|
||||||
|
|
||||||
|
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,
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'mission_executor = rov_mission.mission_executor:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
42
src/rov_navigation/config/ekf.yaml
Normal file
42
src/rov_navigation/config/ekf.yaml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# robot_localization EKF configuration for ROV
|
||||||
|
# Fuses: Xsens IMU (primary), depth sensor
|
||||||
|
# Future: DVL when available
|
||||||
|
|
||||||
|
ekf_filter_node:
|
||||||
|
ros__parameters:
|
||||||
|
frequency: 30.0
|
||||||
|
sensor_timeout: 0.1
|
||||||
|
two_d_mode: false
|
||||||
|
publish_tf: true
|
||||||
|
map_frame: map
|
||||||
|
odom_frame: odom
|
||||||
|
base_link_frame: base_link
|
||||||
|
world_frame: odom
|
||||||
|
|
||||||
|
# Xsens IMU — primary attitude source
|
||||||
|
imu0: /imu/data
|
||||||
|
imu0_config: [false, false, false, # x, y, z position
|
||||||
|
true, true, true, # roll, pitch, yaw
|
||||||
|
false, false, false, # vx, vy, vz
|
||||||
|
true, true, true, # vroll, vpitch, vyaw
|
||||||
|
true, true, true] # ax, ay, az
|
||||||
|
imu0_differential: false
|
||||||
|
imu0_relative: false
|
||||||
|
imu0_remove_gravitational_acceleration: true
|
||||||
|
|
||||||
|
# Process noise — tune after pool testing
|
||||||
|
process_noise_covariance: [0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0.06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0.03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0.03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0.06, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0.025,0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0.025,0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0.04, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.02, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.015]
|
||||||
43
src/rov_navigation/launch/navigation.launch.py
Normal file
43
src/rov_navigation/launch/navigation.launch.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Navigation stack launch file.
|
||||||
|
Launches: state_estimator, depth_node, robot_localization EKF
|
||||||
|
"""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
pkg_dir = get_package_share_directory('rov_navigation')
|
||||||
|
ekf_config = os.path.join(pkg_dir, 'config', 'ekf.yaml')
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
# EKF from robot_localization
|
||||||
|
Node(
|
||||||
|
package='robot_localization',
|
||||||
|
executable='ekf_node',
|
||||||
|
name='ekf_filter_node',
|
||||||
|
output='screen',
|
||||||
|
parameters=[ekf_config],
|
||||||
|
),
|
||||||
|
|
||||||
|
# State estimator
|
||||||
|
Node(
|
||||||
|
package='rov_navigation',
|
||||||
|
executable='state_estimator',
|
||||||
|
name='state_estimator',
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
|
||||||
|
# Depth node
|
||||||
|
Node(
|
||||||
|
package='rov_navigation',
|
||||||
|
executable='depth_node',
|
||||||
|
name='depth_node',
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
])
|
||||||
19
src/rov_navigation/package.xml
Normal file
19
src/rov_navigation/package.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_navigation</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>State estimation, EKF, and localisation for ROV autonomy</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>sensor_msgs</depend>
|
||||||
|
<depend>nav_msgs</depend>
|
||||||
|
<depend>robot_localization</depend>
|
||||||
|
<depend>rov_interfaces</depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
src/rov_navigation/resource/rov_navigation
Normal file
0
src/rov_navigation/resource/rov_navigation
Normal file
0
src/rov_navigation/rov_navigation/__init__.py
Normal file
0
src/rov_navigation/rov_navigation/__init__.py
Normal file
48
src/rov_navigation/rov_navigation/depth_node.py
Normal file
48
src/rov_navigation/rov_navigation/depth_node.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Depth node.
|
||||||
|
Reads depth/pressure from ArduSub via MAVROS and republishes as Float64.
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/mavros/global_position/rel_alt (std_msgs/Float64) — altitude from MAVROS
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/rov/depth (std_msgs/Float64) — depth in metres (positive down)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_msgs.msg import Float64
|
||||||
|
|
||||||
|
|
||||||
|
class DepthNode(Node):
|
||||||
|
"""Republishes depth from MAVROS in a clean topic."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('depth_node')
|
||||||
|
|
||||||
|
self.sub = self.create_subscription(
|
||||||
|
Float64, '/mavros/global_position/rel_alt',
|
||||||
|
self.alt_callback, 10)
|
||||||
|
|
||||||
|
self.pub = self.create_publisher(Float64, '/rov/depth', 10)
|
||||||
|
|
||||||
|
self.get_logger().info('DepthNode started')
|
||||||
|
|
||||||
|
def alt_callback(self, msg: Float64):
|
||||||
|
"""Convert altitude to depth (negate) and republish."""
|
||||||
|
depth_msg = Float64()
|
||||||
|
depth_msg.data = -msg.data # ArduSub reports negative depth as altitude
|
||||||
|
self.pub.publish(depth_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = DepthNode()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
66
src/rov_navigation/rov_navigation/state_estimator.py
Normal file
66
src/rov_navigation/rov_navigation/state_estimator.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
State estimator node.
|
||||||
|
Fuses IMU, depth, and (future) DVL data via robot_localization EKF.
|
||||||
|
Publishes /rov/state as nav_msgs/Odometry.
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/imu/data (sensor_msgs/Imu) — Xsens IMU
|
||||||
|
/mavros/imu/data (sensor_msgs/Imu) — ArduSub IMU (backup)
|
||||||
|
/rov/depth (std_msgs/Float64) — depth from pressure sensor
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/rov/state (nav_msgs/Odometry) — fused state estimate
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import Imu
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from std_msgs.msg import Float64
|
||||||
|
|
||||||
|
|
||||||
|
class StateEstimator(Node):
|
||||||
|
"""Fuses sensor inputs into a single state estimate."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('state_estimator')
|
||||||
|
|
||||||
|
# --- Parameters ---
|
||||||
|
self.declare_parameter('use_xsens', True)
|
||||||
|
self.declare_parameter('use_mavros_imu', False)
|
||||||
|
self.use_xsens = self.get_parameter('use_xsens').value
|
||||||
|
self.use_mavros_imu = self.get_parameter('use_mavros_imu').value
|
||||||
|
|
||||||
|
# --- Subscribers ---
|
||||||
|
self.imu_sub = self.create_subscription(
|
||||||
|
Imu, '/imu/data', self.imu_callback, 10)
|
||||||
|
self.depth_sub = self.create_subscription(
|
||||||
|
Float64, '/rov/depth', self.depth_callback, 10)
|
||||||
|
|
||||||
|
# --- Publishers ---
|
||||||
|
self.state_pub = self.create_publisher(Odometry, '/rov/state', 10)
|
||||||
|
|
||||||
|
self.get_logger().info('StateEstimator node started')
|
||||||
|
|
||||||
|
def imu_callback(self, msg: Imu):
|
||||||
|
"""Handle incoming IMU data."""
|
||||||
|
# TODO: forward to EKF or process directly
|
||||||
|
pass
|
||||||
|
|
||||||
|
def depth_callback(self, msg: Float64):
|
||||||
|
"""Handle incoming depth measurement."""
|
||||||
|
# TODO: incorporate depth into state estimate
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = StateEstimator()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
25
src/rov_navigation/setup.py
Normal file
25
src/rov_navigation/setup.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'rov_navigation'
|
||||||
|
|
||||||
|
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,
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'state_estimator = rov_navigation.state_estimator:main',
|
||||||
|
'depth_node = rov_navigation.depth_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
8
src/rov_perception/config/cameras.yaml
Normal file
8
src/rov_perception/config/cameras.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Camera configuration
|
||||||
|
# Update rtsp_url values to match your IP cameras
|
||||||
|
|
||||||
|
camera_node:
|
||||||
|
ros__parameters:
|
||||||
|
rtsp_url: "rtsp://192.168.2.100:554/stream1"
|
||||||
|
frame_id: "camera_front"
|
||||||
|
fps: 15
|
||||||
27
src/rov_perception/launch/perception.launch.py
Normal file
27
src/rov_perception/launch/perception.launch.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""Perception stack launch file."""
|
||||||
|
|
||||||
|
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():
|
||||||
|
pkg_dir = get_package_share_directory('rov_perception')
|
||||||
|
cam_config = os.path.join(pkg_dir, 'config', 'cameras.yaml')
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
Node(
|
||||||
|
package='rov_perception',
|
||||||
|
executable='camera_node',
|
||||||
|
name='camera_node',
|
||||||
|
output='screen',
|
||||||
|
parameters=[cam_config],
|
||||||
|
),
|
||||||
|
Node(
|
||||||
|
package='rov_perception',
|
||||||
|
executable='feature_detector',
|
||||||
|
name='feature_detector',
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
])
|
||||||
18
src/rov_perception/package.xml
Normal file
18
src/rov_perception/package.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_perception</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Vision pipeline and feature detection for ROV inspection</description>
|
||||||
|
<maintainer email="grant@symbytech.com">Grant</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>sensor_msgs</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
<depend>cv_bridge</depend>
|
||||||
|
<depend>image_transport</depend>
|
||||||
|
<depend>rov_interfaces</depend>
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
src/rov_perception/resource/rov_perception
Normal file
0
src/rov_perception/resource/rov_perception
Normal file
0
src/rov_perception/rov_perception/__init__.py
Normal file
0
src/rov_perception/rov_perception/__init__.py
Normal file
63
src/rov_perception/rov_perception/camera_node.py
Normal file
63
src/rov_perception/rov_perception/camera_node.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Camera node.
|
||||||
|
Pulls RTSP streams from IP cameras and publishes as ROS2 image topics.
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/camera/front/image_raw (sensor_msgs/Image)
|
||||||
|
/camera/front/camera_info (sensor_msgs/CameraInfo)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
rtsp_url — RTSP stream URL
|
||||||
|
frame_id — camera frame name for TF
|
||||||
|
fps — target capture framerate
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import Image, CameraInfo
|
||||||
|
from cv_bridge import CvBridge
|
||||||
|
|
||||||
|
|
||||||
|
class CameraNode(Node):
|
||||||
|
"""Captures RTSP stream and publishes as ROS2 image topic."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('camera_node')
|
||||||
|
|
||||||
|
# --- Parameters ---
|
||||||
|
self.declare_parameter('rtsp_url', 'rtsp://192.168.1.100:554/stream')
|
||||||
|
self.declare_parameter('frame_id', 'camera_front')
|
||||||
|
self.declare_parameter('fps', 15)
|
||||||
|
|
||||||
|
self.rtsp_url = self.get_parameter('rtsp_url').value
|
||||||
|
self.frame_id = self.get_parameter('frame_id').value
|
||||||
|
self.fps = self.get_parameter('fps').value
|
||||||
|
|
||||||
|
# --- Publishers ---
|
||||||
|
self.image_pub = self.create_publisher(Image, '/camera/front/image_raw', 10)
|
||||||
|
self.info_pub = self.create_publisher(CameraInfo, '/camera/front/camera_info', 10)
|
||||||
|
|
||||||
|
self.bridge = CvBridge()
|
||||||
|
|
||||||
|
# TODO: initialise OpenCV VideoCapture with rtsp_url
|
||||||
|
# TODO: create timer at self.fps rate to capture and publish frames
|
||||||
|
|
||||||
|
self.get_logger().info(f'CameraNode started — stream: {self.rtsp_url}')
|
||||||
|
|
||||||
|
def capture_and_publish(self):
|
||||||
|
"""Capture one frame and publish."""
|
||||||
|
# TODO: implement frame capture and publish
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = CameraNode()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
48
src/rov_perception/rov_perception/feature_detector.py
Normal file
48
src/rov_perception/rov_perception/feature_detector.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Feature detector node.
|
||||||
|
Detects inspection-relevant features (corrosion, marine growth, damage) in camera frames.
|
||||||
|
Phase 5 implementation — stub only for Phase 1.
|
||||||
|
|
||||||
|
Topics subscribed:
|
||||||
|
/camera/front/image_raw (sensor_msgs/Image)
|
||||||
|
|
||||||
|
Topics published:
|
||||||
|
/perception/detections (std_msgs/String) — JSON detection results (placeholder)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import Image
|
||||||
|
from std_msgs.msg import String
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureDetector(Node):
|
||||||
|
"""Detects features in camera images for inspection reporting."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('feature_detector')
|
||||||
|
|
||||||
|
self.sub = self.create_subscription(
|
||||||
|
Image, '/camera/front/image_raw', self.image_callback, 10)
|
||||||
|
|
||||||
|
self.pub = self.create_publisher(String, '/perception/detections', 10)
|
||||||
|
|
||||||
|
self.get_logger().info('FeatureDetector node started (stub)')
|
||||||
|
|
||||||
|
def image_callback(self, msg: Image):
|
||||||
|
"""Process incoming image frame."""
|
||||||
|
# TODO Phase 5: run inference, publish detections
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = FeatureDetector()
|
||||||
|
rclpy.spin(node)
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
25
src/rov_perception/setup.py
Normal file
25
src/rov_perception/setup.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'rov_perception'
|
||||||
|
|
||||||
|
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,
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'camera_node = rov_perception.camera_node:main',
|
||||||
|
'feature_detector = rov_perception.feature_detector:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user