diff --git a/src/rov_interfaces/CMakeLists.txt b/src/rov_interfaces/CMakeLists.txt index 5f73af1..97ae244 100644 --- a/src/rov_interfaces/CMakeLists.txt +++ b/src/rov_interfaces/CMakeLists.txt @@ -9,6 +9,7 @@ rosidl_generate_interfaces(${PROJECT_NAME} "msg/MissionStatus.msg" "msg/FailsafeStatus.msg" "msg/InspectionWaypoint.msg" + "msg/ModeProfile.msg" "srv/MissionCommand.srv" DEPENDENCIES std_msgs geometry_msgs ) diff --git a/src/rov_interfaces/msg/ModeProfile.msg b/src/rov_interfaces/msg/ModeProfile.msg new file mode 100644 index 0000000..08ff49d --- /dev/null +++ b/src/rov_interfaces/msg/ModeProfile.msg @@ -0,0 +1,92 @@ +std_msgs/Header header + +# ============================================================================= +# ModeProfile.msg — resolved operating-mode flag set. +# +# Published by mode_profile_loader on /rov/mode/profile whenever the active +# profile is loaded or changed. Carries the FULLY RESOLVED flags for the +# active profile (ROV / AUV / a saved custom-hybrid), so every consumer sees +# one atomic, self-consistent set. Widgets read these flags (via the Cockpit +# data lake) and adapt their own behaviour — they contain no mode logic. +# +# Source of truth is mode_profiles.yaml on the RPi5; this message is the +# runtime broadcast of the currently-active resolved profile. +# ============================================================================= + +# --- Active profile identity ------------------------------------------------ +# Name of the active profile as it appears in mode_profiles.yaml. +# Standard values are "ROV" and "AUV"; a saved hybrid carries its custom name. +string profile_name + +# Base mode this profile derives from — lets consumers reason about defaults +# without parsing the name. A hybrid reports the base it was saved from. +uint8 base_mode +uint8 BASE_ROV=0 +uint8 BASE_AUV=1 + +# --- HILT: the dominant axis ------------------------------------------------ +# Whether a live human-in-the-loop link is present for the whole run. +# ROV = present (widgets are live interfaces); AUV = absent once submerged +# (widgets serve pre-dive setup + post-dive replay only). +bool hilt_present + +# --- System health role (W1) ------------------------------------------------ +# advisory = display only, pilot decides (ROV). +# gate = pre-dive GO/NO-GO; RED blocks mission start (AUV). +uint8 health_role +uint8 HEALTH_ADVISORY=0 +uint8 HEALTH_GATE=1 + +# --- Recording control (independent of HILT) -------------------------------- +# manual = operator starts/stops recording (ROV reflex). +# auto = recording auto-starts on ARM, stops on DISARM (AUV reflex), +# bounded per armed run. Single ARM-bounded black-box model. +uint8 recording_control +uint8 REC_MANUAL=0 +uint8 REC_AUTO=1 + +# No-go gate coupling for recording. +# relaxed = recording-off is not a hard no-go (ROV/dev). +# enforced = recording failure is a hard no-go (AUV, DIR-9 as amended). +uint8 nogo_gate +uint8 NOGO_RELAXED=0 +uint8 NOGO_ENFORCED=1 + +# Reminder behaviour on the ARM edge, keyed to recording_control=manual. +# true = on transition to ARMED with manual control and recorder not active, +# the Recording Control widget prompts ("armed, not recording"). +# false = no reminder (auto mode acts on ARM instead; nothing to prompt). +bool record_reminder_on_arm + +# --- Return To Safe (W3) ---------------------------------------------------- +# operator_selectable = operator chooses automated return OR switch to manual +# and fly out (ROV / HILT). +# autonomous = parameter-driven return, no operator in loop (AUV). +uint8 return_mode +uint8 RETURN_OPERATOR_SELECTABLE=0 +uint8 RETURN_AUTONOMOUS=1 + +# --- Live telemetry (W2 Mission Status, W5 Return Budget, nav) --------------- +# on = live operator interfaces throughout (ROV). +# predive_only = useful on the surface up to launch, then dark — no link (AUV). +uint8 live_telemetry +uint8 TELEM_ON=0 +uint8 TELEM_PREDIVE_ONLY=1 + +# --- Nav display (W6 Altitude + depth/heading/attitude) --------------------- +# true = shown to the pilot to fly on (ROV). +# false = feeds the autonomy stack, not shown as a live readout (AUV). +bool nav_display + +# --- Camera role ------------------------------------------------------------ +# pilot_primary = live view is the pilot's primary flight reference (ROV). +# record_only = recorded for dev/ML only (AUV). +# NOTE: recorded video is never a client deliverable in either mode until +# Class-approved devices are added — a separate constraint from this flag. +uint8 camera_role +uint8 CAMERA_PILOT_PRIMARY=0 +uint8 CAMERA_RECORD_ONLY=1 + +# --- Human-readable status -------------------------------------------------- +# e.g. "Loaded profile 'ROV'" — for logs and diagnostic widgets. +string message diff --git a/src/rov_mission/config/mode_profiles.yaml b/src/rov_mission/config/mode_profiles.yaml new file mode 100644 index 0000000..7ffbf98 --- /dev/null +++ b/src/rov_mission/config/mode_profiles.yaml @@ -0,0 +1,90 @@ +# ============================================================================= +# mode_profiles.yaml — Argonaut 3 operating-mode profiles +# ----------------------------------------------------------------------------- +# SINGLE SOURCE OF TRUTH for mode-driven behaviour. Lives in rov_mission/config +# so it is version-controlled in Gitea and captured by the RPi5 workspace +# backup (CREATE THEN BACKUP satisfied with no extra step). +# +# HOW IT IS USED +# 1. mode_profile_loader (rov_mission) reads this file and the `active` key, +# resolves the chosen profile's flags into a ModeProfile message, and +# publishes it latched on /rov/mode/profile. +# 2. cockpit_bridge subscribes to that topic and surfaces the flags to the +# Cockpit data lake as external/rov-* variables (same road every other +# value already travels). Widgets read the flags; they hold no mode logic. +# 3. The setup wizard (from W4) selects a mode, shows the resolved flags, +# lets them be edited, writes back here via rov_api, then calls the +# loader's /rov/mode/reload service. Editing a flag off its mode default +# = a hybrid; "Save As" persists it under a new name for reuse. +# +# WHY THIS SHAPE +# One widget set, behaviour set by flags. Dominant flag is HILT: +# ROV = HILT present -> widgets are LIVE operator interfaces. +# AUV = HILT absent -> headless from parameters during the dive; widgets +# serve pre-dive setup + post-dive replay only. +# Recording is the one axis INDEPENDENT of HILT and has its own flags and +# its own ARM-bounded trigger (single black-box model). +# +# HYBRID is not a listed mode. It is any profile whose flags were hand-edited +# off a pure ROV/AUV default and saved under a custom name. +# ============================================================================= + +schema_version: 1 + +# Currently selected profile. Set by the setup wizard. The loader publishes +# this profile's resolved flags on startup and whenever reloaded. +active: ROV + + +profiles: + + # --------------------------------------------------------------------------- + # ROV — HILT PRESENT (tethered, real-time human-in-the-loop) + # Widgets are live operator interfaces; the pilot reads them and acts. + # --------------------------------------------------------------------------- + ROV: + hilt: present # live link present for the whole dive + health_role: advisory # W1 informs the pilot; blocks nothing + recording_control: manual # operator starts/stops (ROV reflex) + nogo_gate: relaxed # recording-off is not a hard no-go here + record_reminder_on_arm: on # ARM prompts "armed, not recording" + return_mode: operator_selectable # auto-return OR switch to manual & fly out + live_telemetry: "on" # W2/W5 + nav are live operator interfaces + nav_display: on # W6/depth/heading shown to pilot to fly + camera_role: pilot_primary # live view is the primary flight reference + + # --------------------------------------------------------------------------- + # AUV — HILT ABSENT once submerged (autonomous, parameter-driven) + # Widgets are pre-dive setup/verification + post-dive replay. + # --------------------------------------------------------------------------- + AUV: + hilt: absent # no live link during the dive + health_role: gate # pre-dive GO/NO-GO; RED blocks start + recording_control: auto # auto-record on ARM, stop on DISARM + nogo_gate: enforced # recording failure is a hard no-go (DIR-9) + record_reminder_on_arm: off # automatic; nothing to remind + return_mode: autonomous # parameter-driven return, no operator + live_telemetry: predive_only # useful on surface only; dark in dive + nav_display: off # nav feeds autonomy, not a live readout + camera_role: record_only # recorded for dev/ML, not a deliverable + + +# ============================================================================= +# CUSTOM / HYBRID PROFILES (written by the wizard's "Save As") +# ----------------------------------------------------------------------------- +# Example only (commented out). A hybrid is a full flag set saved under a new +# name — e.g. an ROV dev session that auto-records like an AUV. 'base' records +# which standard mode it derived from so the loader can report base_mode. +# +# ROV_DEV_AUTORECORD: +# base: ROV +# hilt: present +# health_role: advisory +# recording_control: auto # borrowed AUV reflex (the override) +# nogo_gate: relaxed +# record_reminder_on_arm: off +# return_mode: operator_selectable +# live_telemetry: on +# nav_display: on +# camera_role: pilot_primary +# ============================================================================= diff --git a/src/rov_mission/package.xml b/src/rov_mission/package.xml index d10c22f..c0387ce 100644 --- a/src/rov_mission/package.xml +++ b/src/rov_mission/package.xml @@ -10,6 +10,9 @@ std_msgs geometry_msgs rov_interfaces + std_srvs + ament_index_python + python3-yaml ament_python diff --git a/src/rov_mission/rov_mission/cockpit_bridge.py b/src/rov_mission/rov_mission/cockpit_bridge.py index 034ef89..1bd78e3 100644 --- a/src/rov_mission/rov_mission/cockpit_bridge.py +++ b/src/rov_mission/rov_mission/cockpit_bridge.py @@ -24,10 +24,10 @@ import asyncio import threading import rclpy from rclpy.node import Node -from rclpy.qos import QoSProfile, ReliabilityPolicy +from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy from std_msgs.msg import Bool, Float64 from sensor_msgs.msg import BatteryState, Range -from rov_interfaces.msg import FailsafeStatus, MissionStatus +from rov_interfaces.msg import FailsafeStatus, MissionStatus, ModeProfile import websockets # Port Cockpit connects to as a Generic WebSocket client @@ -91,6 +91,47 @@ class CockpitBridge(Node): # Updated by _altitude_cb when /ping2/altitude messages arrive. # Remains -1.0 until the Ping2 sonar is connected and publishing. 'rov-altitude': -1.0, + + # --- Mode profile flags (from mode_profile_loader) --------- + # Resolved active operating profile, surfaced so widgets can + # adapt behaviour by flag rather than containing mode logic. + # Values mirror ModeProfile.msg enum constants (ints) and the + # profile name string. Defaults below apply until the first + # /rov/mode/profile message arrives (loader latches, so this + # is effectively immediate once both nodes are up). + + # Active profile name, e.g. 'ROV', 'AUV', or a saved hybrid. + 'rov-mode': 'ROV', + + # base_mode: 0=ROV, 1=AUV (ModeProfile.BASE_*) + 'rov-mode-base': 0, + + # hilt_present: 1=live human-in-the-loop link, 0=absent + 'rov-hilt': 1, + + # health_role: 0=advisory (pilot decides), 1=gate (blocks start) + 'rov-health-role': 0, + + # recording_control: 0=manual (operator), 1=auto (ARM-bounded) + 'rov-rec-control': 0, + + # nogo_gate: 0=relaxed, 1=enforced (recording no-go coupling) + 'rov-nogo-gate': 0, + + # record_reminder_on_arm: 1=prompt on ARM when manual & idle + 'rov-rec-reminder': 1, + + # return_mode: 0=operator_selectable, 1=autonomous + 'rov-return-mode': 0, + + # live_telemetry: 0=on, 1=predive_only + 'rov-live-telem': 0, + + # nav_display: 1=shown to pilot, 0=feeds autonomy only + 'rov-nav-display': 1, + + # camera_role: 0=pilot_primary, 1=record_only + 'rov-camera-role': 0, } # Set of currently connected Cockpit WebSocket clients. @@ -149,6 +190,20 @@ class CockpitBridge(Node): Range, '/ping2/altitude', self._altitude_cb, 10) + # Resolved operating-mode profile from mode_profile_loader. + # Latched (TRANSIENT_LOCAL, depth 1) to match the publisher, so we + # receive the current profile immediately even if the loader + # started first. Reliable + KEEP_LAST for a single latest value. + mode_qos = QoSProfile( + depth=1, + history=HistoryPolicy.KEEP_LAST, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL, + ) + self.create_subscription( + ModeProfile, '/rov/mode/profile', + self._mode_cb, mode_qos) + # ------------------------------------------------------------------ # Timers # ------------------------------------------------------------------ @@ -226,6 +281,28 @@ class CockpitBridge(Node): # Round to 2dp for display — Ping2 is ±1% accuracy at range self._values['rov-altitude'] = round(raw, 2) + def _mode_cb(self, msg: ModeProfile): + """ + Update mode-profile flags from mode_profile_loader. + + Copies each resolved flag into _values so it is broadcast to + Cockpit as external/rov-*. Enum fields are already ints in the + message; the profile name is a string. Widgets read these to + adapt behaviour (they hold no mode logic themselves). + """ + with self._lock: + self._values['rov-mode'] = str(msg.profile_name) + self._values['rov-mode-base'] = int(msg.base_mode) + self._values['rov-hilt'] = 1 if msg.hilt_present else 0 + self._values['rov-health-role'] = int(msg.health_role) + self._values['rov-rec-control'] = int(msg.recording_control) + self._values['rov-nogo-gate'] = int(msg.nogo_gate) + self._values['rov-rec-reminder'] = 1 if msg.record_reminder_on_arm else 0 + self._values['rov-return-mode'] = int(msg.return_mode) + self._values['rov-live-telem'] = int(msg.live_telemetry) + self._values['rov-nav-display'] = 1 if msg.nav_display else 0 + self._values['rov-camera-role'] = int(msg.camera_role) + # ------------------------------------------------------------------ # WebSocket broadcast — called from ROS2 timer at 2 Hz # ------------------------------------------------------------------ diff --git a/src/rov_mission/rov_mission/mode_profile_loader.py b/src/rov_mission/rov_mission/mode_profile_loader.py new file mode 100644 index 0000000..f6ed88f --- /dev/null +++ b/src/rov_mission/rov_mission/mode_profile_loader.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +mode_profile_loader.py — publishes the active operating-mode profile. + +Reads mode_profiles.yaml (the single source of truth on the RPi5), resolves +the active profile's flags into a ModeProfile message, and publishes it on +/rov/mode/profile with TRANSIENT_LOCAL durability so that any node or bridge +that starts later still receives the current profile (latched behaviour). + +Design: + - Widgets contain no mode logic. They read the resolved flags (surfaced to + the Cockpit data lake by cockpit_bridge) and adapt their own behaviour. + - Flags always change together as a set, so they are published as ONE + atomic ModeProfile message rather than a scatter of single-value topics. + - The active profile can be changed at runtime by rewriting the YAML (via + the setup wizard / rov_api) and asking this node to reload — exposed here + as a std_srvs/Trigger service '/rov/mode/reload'. + +This node deliberately holds no mode logic of its own beyond mapping the YAML +string values to the ModeProfile enum constants. If the YAML and the message +disagree on allowed values, that is surfaced as an error and the previous +valid profile is retained (fail-safe: never publish a malformed profile). +""" + +import os + +import rclpy +from rclpy.node import Node +from rclpy.qos import QoSProfile, DurabilityPolicy, ReliabilityPolicy, HistoryPolicy +from std_srvs.srv import Trigger + +import yaml +from ament_index_python.packages import get_package_share_directory + +from rov_interfaces.msg import ModeProfile + + +# --------------------------------------------------------------------------- +# Mapping tables: YAML string values -> ModeProfile enum constants. +# Keeping these as explicit dicts (rather than clever lookups) makes the +# allowed value set obvious and makes an invalid YAML value easy to reject. +# --------------------------------------------------------------------------- + +BASE_MODE_MAP = { + 'ROV': ModeProfile.BASE_ROV, + 'AUV': ModeProfile.BASE_AUV, +} + +HEALTH_ROLE_MAP = { + 'advisory': ModeProfile.HEALTH_ADVISORY, + 'gate': ModeProfile.HEALTH_GATE, +} + +RECORDING_CONTROL_MAP = { + 'manual': ModeProfile.REC_MANUAL, + 'auto': ModeProfile.REC_AUTO, +} + +NOGO_GATE_MAP = { + 'relaxed': ModeProfile.NOGO_RELAXED, + 'enforced': ModeProfile.NOGO_ENFORCED, +} + +RETURN_MODE_MAP = { + 'operator_selectable': ModeProfile.RETURN_OPERATOR_SELECTABLE, + 'autonomous': ModeProfile.RETURN_AUTONOMOUS, +} + +LIVE_TELEMETRY_MAP = { + 'on': ModeProfile.TELEM_ON, + 'predive_only': ModeProfile.TELEM_PREDIVE_ONLY, +} + +CAMERA_ROLE_MAP = { + 'pilot_primary': ModeProfile.CAMERA_PILOT_PRIMARY, + 'record_only': ModeProfile.CAMERA_RECORD_ONLY, +} + + +class ModeProfileLoader(Node): + """ + ROS2 node that loads mode_profiles.yaml and publishes the resolved + active profile as a latched ModeProfile message. + """ + + def __init__(self): + super().__init__('mode_profile_loader') + + # ------------------------------------------------------------------ + # Parameter: path to mode_profiles.yaml. + # Defaults to the installed config dir of rov_mission, but can be + # overridden (e.g. for testing) via: + # ros2 run rov_mission mode_profile_loader \ + # --ros-args -p profiles_path:=/abs/path/mode_profiles.yaml + # ------------------------------------------------------------------ + default_path = os.path.join( + get_package_share_directory('rov_mission'), + 'config', 'mode_profiles.yaml') + self.declare_parameter('profiles_path', default_path) + self._profiles_path = self.get_parameter( + 'profiles_path').get_parameter_value().string_value + + # Last successfully published profile message. Retained so that if a + # later reload fails validation we keep serving the last good profile + # rather than publishing something malformed. + self._last_good_msg = None + + # ------------------------------------------------------------------ + # Publisher: latched (TRANSIENT_LOCAL) so late subscribers — including + # cockpit_bridge if it starts after us — receive the current profile + # immediately on subscription. depth=1 because only the latest + # resolved profile matters. + # ------------------------------------------------------------------ + latched_qos = QoSProfile( + depth=1, + history=HistoryPolicy.KEEP_LAST, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL, + ) + self._pub = self.create_publisher( + ModeProfile, '/rov/mode/profile', latched_qos) + + # ------------------------------------------------------------------ + # Reload service: lets the setup wizard / rov_api trigger a re-read + # after it rewrites the YAML, without restarting the node. + # ------------------------------------------------------------------ + self._reload_srv = self.create_service( + Trigger, '/rov/mode/reload', self._on_reload) + + # Initial load at startup. + ok, detail = self._load_and_publish() + if ok: + self.get_logger().info(f'mode_profile_loader started: {detail}') + else: + # Startup load failed and there is no prior good profile to serve. + # Log loudly; the node stays up so a corrected YAML can be loaded + # via the reload service without a restart. + self.get_logger().error( + f'mode_profile_loader started but NO valid profile published: ' + f'{detail}') + + # ------------------------------------------------------------------ + # Reload service handler + # ------------------------------------------------------------------ + + def _on_reload(self, request, response): + """ + std_srvs/Trigger handler — re-read the YAML and republish. + Returns success=False with a message if the new file is invalid; + in that case the previous good profile remains the published one. + """ + ok, detail = self._load_and_publish() + response.success = ok + response.message = detail + if ok: + self.get_logger().info(f'Reloaded profile: {detail}') + else: + self.get_logger().warn(f'Reload rejected: {detail}') + return response + + # ------------------------------------------------------------------ + # Core: load YAML, resolve active profile, validate, publish + # ------------------------------------------------------------------ + + def _load_and_publish(self): + """ + Read the YAML, build a ModeProfile from the active profile, and + publish it. Returns (ok: bool, detail: str). + + On any error the previously published profile (if any) is left in + place — we never publish a partially-built or malformed profile. + """ + # --- Read + parse the file ------------------------------------------ + try: + with open(self._profiles_path, 'r') as f: + data = yaml.safe_load(f) + except FileNotFoundError: + return False, f'profiles file not found: {self._profiles_path}' + except yaml.YAMLError as e: + return False, f'YAML parse error: {e}' + + if not isinstance(data, dict): + return False, 'profiles file is empty or not a mapping' + + # --- Resolve the active profile name -------------------------------- + active = data.get('active') + if not active: + return False, "no 'active' profile specified" + + profiles = data.get('profiles', {}) + if active not in profiles: + return False, f"active profile '{active}' not found in profiles" + + profile = profiles[active] + + # --- Build the message (validates every field via the maps) --------- + try: + msg = self._build_msg(active, profile) + except KeyError as e: + # A flag held a value not present in the mapping table. + return False, f"invalid flag value in profile '{active}': {e}" + except Exception as e: + return False, f"error building profile '{active}': {e}" + + # --- Publish + retain as last-good ---------------------------------- + self._pub.publish(msg) + self._last_good_msg = msg + return True, f"'{active}' published" + + def _build_msg(self, name, profile): + """ + Translate one YAML profile dict into a ModeProfile message. + + Every enum lookup uses the mapping tables; a missing/invalid value + raises KeyError, which the caller turns into a clean rejection. + The base_mode is taken from an explicit 'base' key if present (saved + hybrids record the base they derive from); otherwise inferred from + the standard names, then from HILT presence as a last resort. + """ + msg = ModeProfile() + msg.header.stamp = self.get_clock().now().to_msg() + + # Active profile identity. + msg.profile_name = str(name) + + # Base mode resolution (explicit 'base' -> standard name -> HILT). + base_key = str(profile.get('base', name)) + if base_key in BASE_MODE_MAP: + msg.base_mode = BASE_MODE_MAP[base_key] + else: + msg.base_mode = ( + ModeProfile.BASE_ROV + if profile.get('hilt') == 'present' + else ModeProfile.BASE_AUV) + + # HILT axis. + msg.hilt_present = (profile['hilt'] == 'present') + + # Enum flags — each raises KeyError on an unknown value. + msg.health_role = HEALTH_ROLE_MAP[self._enum_key(profile['health_role'])] + msg.recording_control = RECORDING_CONTROL_MAP[self._enum_key(profile['recording_control'])] + msg.nogo_gate = NOGO_GATE_MAP[self._enum_key(profile['nogo_gate'])] + msg.return_mode = RETURN_MODE_MAP[self._enum_key(profile['return_mode'])] + msg.live_telemetry = LIVE_TELEMETRY_MAP[self._enum_key(profile['live_telemetry'])] + msg.camera_role = CAMERA_ROLE_MAP[self._enum_key(profile['camera_role'])] + + # Boolean flags — YAML 'on'/'off' or true/false both accepted. + msg.record_reminder_on_arm = self._as_bool( + profile['record_reminder_on_arm']) + msg.nav_display = self._as_bool(profile['nav_display']) + + # Human-readable status for logs / diagnostic widgets. + msg.message = f"Loaded profile '{name}'" + return msg + + @staticmethod + def _enum_key(value): + """ + Normalise a YAML flag value to the lowercase string key used + by the enum mapping tables. + + YAML 1.1 turns bare on/off/yes/no/true/false into Python + booleans, so an unquoted enum value like `on` arrives as True + and would miss a string-keyed map. Map booleans back to + 'on'/'off'; stringify and lowercase everything else. Unknown + values still miss the map and are rejected upstream. + """ + if value is True: + return 'on' + if value is False: + return 'off' + return str(value).strip().lower() + + @staticmethod + def _as_bool(value): + """ + Coerce a YAML flag to bool. Accepts real booleans and the strings + 'on'/'off'/'true'/'false' (case-insensitive). Anything else raises, + so a typo cannot silently become False. + """ + if isinstance(value, bool): + return value + s = str(value).strip().lower() + if s in ('on', 'true', 'yes', '1'): + return True + if s in ('off', 'false', 'no', '0'): + return False + raise ValueError(f'not a boolean flag: {value!r}') + + +def main(args=None): + """Entry point — spin the loader node.""" + rclpy.init(args=args) + node = ModeProfileLoader() + try: + rclpy.spin(node) + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/src/rov_mission/setup.py b/src/rov_mission/setup.py index fb52fb5..d33ca25 100644 --- a/src/rov_mission/setup.py +++ b/src/rov_mission/setup.py @@ -27,6 +27,10 @@ setup( # Continuous MCAP recorder — DIR-9 foundational node # Must be active before any mission can start (no-go gate) 'recording_manager = rov_mission.recording_manager:main', + + # Mode profile loader — reads mode_profiles.yaml, publishes the + # resolved active profile (ROV/AUV/hybrid) on /rov/mode/profile + 'mode_profile_loader = rov_mission.mode_profile_loader:main', ], }, )