Initial workspace — six-package autonomy stack

This commit is contained in:
Grant 2026-05-03 09:26:04 +00:00
commit 9b03548533
46 changed files with 1344 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View 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 "$@"

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

View 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',
),
])

View 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')
),
),
])

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

View 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

View 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',
),
])

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

View File

View File

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

View 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
View 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',
],
},
)

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

View 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

View 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

View 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

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

View File

@ -0,0 +1,6 @@
string command
string mission_id
string[] parameters
---
bool success
string message

View 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

View 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

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

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

View File

View File

View 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
View 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',
],
},
)

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

View 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',
),
])

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

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

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

View 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',
],
},
)

View 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

View 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',
),
])

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

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

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

View 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',
],
},
)