From 9109439707e6ced0a109ba0cb7f139b034e6ee64 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 8 Jul 2026 12:14:10 +0200 Subject: [PATCH] feat(api): add GET /mode and POST /mode endpoints to rov_api --- src/rov_api/rov_api/api_node.py | 319 +++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 7 deletions(-) diff --git a/src/rov_api/rov_api/api_node.py b/src/rov_api/rov_api/api_node.py index 4bc823f..d95772e 100755 --- a/src/rov_api/rov_api/api_node.py +++ b/src/rov_api/rov_api/api_node.py @@ -20,6 +20,11 @@ ARCHITECTURE (Bool, W3 RETURN TO SAFE) (MissionCommand srv) /rov/mission/status START / ABORT /rov/recording/active /rov/failsafe + /rov/mode/profile (latched) + | + call /rov/mode/reload + (std_srvs/Trigger, after + rewriting mode_profiles.yaml) WHY THIS DESIGN --------------- @@ -36,6 +41,8 @@ ENDPOINTS (core only — Stage 2b adds /return_budget, /backup/*, /mission/uploa POST /abort -> W3 RETURN TO SAFE (publishes Bool true to /rov/mission/abort) POST /mission/start -> MissionCommand START POST /mission/stop -> MissionCommand ABORT + GET /mode -> currently active, fully-resolved mode profile + POST /mode -> switch active profile + trigger mode_profile_loader reload VERIFIED INTERFACES (read from source before writing — do not change without re-checking) ----------------------------------------------------------------------------------------- @@ -50,18 +57,38 @@ VERIFIED INTERFACES (read from source before writing — do not change without r /rov/mission/status : rov_interfaces/msg/MissionStatus (state, progress_percent, ...) /rov/recording/active: std_msgs/Bool (recording_manager no-go gate) /rov/failsafe : rov_interfaces/msg/FailsafeStatus (failsafe_state, assessment_state, ...) + /rov/mode/profile : rov_interfaces/msg/ModeProfile, published LATCHED (TRANSIENT_LOCAL, + depth=1) by mode_profile_loader (rov_mission). Subscriber QoS here + MUST also use TRANSIENT_LOCAL or the latched message is never + delivered to a subscriber that starts after the publisher. + /rov/mode/reload : std_srvs/Trigger, served by mode_profile_loader. Re-reads + mode_profiles.yaml and republishes /rov/mode/profile. Rejects + (success=False) and keeps the previous good profile if the YAML + is invalid — see mode_profile_loader._load_and_publish. + mode_profiles.yaml : source of truth, read by mode_profile_loader from its package + share directory. rov_api writes ONLY the top-level 'active:' + line (see RovApiNode._set_active_line) — never a full YAML + round-trip, which would strip the file's comments. """ +import os +import re import threading from typing import Optional # ROS2 import rclpy from rclpy.node import Node +from rclpy.qos import QoSProfile, DurabilityPolicy, ReliabilityPolicy, HistoryPolicy from std_msgs.msg import Bool -from rov_interfaces.msg import MissionStatus, FailsafeStatus +from std_srvs.srv import Trigger +from rov_interfaces.msg import MissionStatus, FailsafeStatus, ModeProfile from rov_interfaces.srv import MissionCommand +# Reading/writing mode_profiles.yaml +import yaml +from ament_index_python.packages import get_package_share_directory + # FastAPI from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -82,11 +109,17 @@ API_PORT = 8081 # until then there is no external exposure (Tailscale disabled, no proxy). API_HOST = '0.0.0.0' -# Timeout (seconds) to wait for the mission_executor service to become -# available before failing a mission command request. Kept short so the +# Timeout (seconds) to wait for the mission_executor / mode_profile_loader +# service to become available before failing a request. Kept short so the # widget gets a fast, clear error rather than hanging. SERVICE_WAIT_TIMEOUT_S = 2.0 +# Timeout (seconds) to wait for the /rov/mode/reload response once the +# service is confirmed available. Slightly longer than the mission-command +# equivalent because a reload also re-parses and validates the whole YAML +# file, not just a single in-memory command. +MODE_RELOAD_TIMEOUT_S = 3.0 + # --------------------------------------------------------------------------- # Pydantic request models @@ -108,6 +141,18 @@ class MissionStartRequest(BaseModel): parameters: list[str] = [] +class ModeSetRequest(BaseModel): + """ + Body for POST /mode. + + profile_name : the profile to activate, exactly as it appears as a key + under 'profiles' in mode_profiles.yaml (e.g. 'ROV', + 'AUV', or a saved custom-hybrid name). Required — there + is no sensible default for "which mode to switch to". + """ + profile_name: str + + # --------------------------------------------------------------------------- # ROS2 node # --------------------------------------------------------------------------- @@ -119,12 +164,15 @@ class RovApiNode(Node): Owns: - a publisher on /rov/mission/abort (Bool) for W3 RETURN TO SAFE - a service client on /rov/mission/command for mission START/STOP + - a service client on /rov/mode/reload for switching mode profiles - subscriptions caching the latest mission status, recording state, - and failsafe state, used to answer GET /health without blocking + failsafe state, and resolved mode profile, used to answer GET + /health and GET /mode without blocking All cached values are plain Python types updated inside subscription callbacks. Reads from the FastAPI thread are atomic (single attribute - reads of immutable values) so no explicit lock is required for them. + reads of immutable values, or whole-dict reference reads) so no + explicit lock is required for them. """ def __init__(self): @@ -148,6 +196,26 @@ class RovApiNode(Node): # Latest failsafe state integer (FailsafeStatus.*); -1 = no data self._failsafe_state: int = -1 + # ------------------------------------------------------------------ + # Cached latest resolved mode profile for GET /mode. + # None until mode_profile_loader's latched message is received — + # since the topic is TRANSIENT_LOCAL this should arrive within + # moments of this node's subscription being created, well before + # any HTTP request can plausibly arrive. + # ------------------------------------------------------------------ + self._mode_profile: Optional[dict] = None + + # ------------------------------------------------------------------ + # Path to mode_profiles.yaml — MUST match the path mode_profile_loader + # actually reads at runtime (its package share directory), because + # rov_api edits this exact file and then asks that node to reload it. + # If the two ever disagree (e.g. one uses --symlink-install and the + # other doesn't), a reload here would silently have no effect. + # ------------------------------------------------------------------ + self._mode_profiles_path = os.path.join( + get_package_share_directory('rov_mission'), + 'config', 'mode_profiles.yaml') + # ------------------------------------------------------------------ # Publisher — /rov/mission/abort (W3 RETURN TO SAFE) # Publishing Bool(true) sets flag_manual_abort in failsafe_monitor, @@ -167,7 +235,16 @@ class RovApiNode(Node): MissionCommand, '/rov/mission/command') # ------------------------------------------------------------------ - # Subscriptions — cache latest values for /health + # Service client — /rov/mode/reload + # Triggers mode_profile_loader to re-read mode_profiles.yaml after + # rov_api has rewritten the 'active:' line, and republish + # /rov/mode/profile. See set_active_profile() for the full flow. + # ------------------------------------------------------------------ + self._mode_reload_cli = self.create_client( + Trigger, '/rov/mode/reload') + + # ------------------------------------------------------------------ + # Subscriptions — cache latest values for /health and /mode # ------------------------------------------------------------------ # Mission status from mission_executor (2 Hz) @@ -185,6 +262,21 @@ class RovApiNode(Node): FailsafeStatus, '/rov/failsafe', self._failsafe_cb, 10) + # Mode profile — published LATCHED (TRANSIENT_LOCAL, depth=1) by + # mode_profile_loader. Our subscriber QoS must match durability + # (TRANSIENT_LOCAL) or we will never receive the latched message, + # since a VOLATILE subscriber only sees messages published AFTER + # it subscribes. + 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_profile_cb, mode_qos) + self.get_logger().info('RovApiNode started — HTTP bridge on :%d' % API_PORT) # ------------------------------------------------------------------ @@ -204,6 +296,29 @@ class RovApiNode(Node): """Cache the latest failsafe state for /health.""" self._failsafe_state = int(msg.failsafe_state) + def _mode_profile_cb(self, msg: ModeProfile): + """ + Cache the latest resolved mode profile for GET /mode. + + Stored as a plain dict (not the ROS message object) so a FastAPI + request thread reading self._mode_profile gets an atomic reference + to an immutable-in-practice dict, never a half-updated ROS message. + """ + self._mode_profile = { + 'profile_name': msg.profile_name, + 'base_mode': int(msg.base_mode), + 'hilt_present': bool(msg.hilt_present), + 'health_role': int(msg.health_role), + 'recording_control': int(msg.recording_control), + 'nogo_gate': int(msg.nogo_gate), + 'record_reminder_on_arm': bool(msg.record_reminder_on_arm), + 'return_mode': int(msg.return_mode), + 'live_telemetry': int(msg.live_telemetry), + 'nav_display': bool(msg.nav_display), + 'camera_role': int(msg.camera_role), + 'message': msg.message, + } + # ------------------------------------------------------------------ # Actions called from the FastAPI thread # ------------------------------------------------------------------ @@ -281,6 +396,134 @@ class RovApiNode(Node): return bool(result.success), str(result.message) + # ------------------------------------------------------------------ + # Mode profile — read/validate/write mode_profiles.yaml, then reload + # ------------------------------------------------------------------ + + def _read_profile_names(self): + """ + Read mode_profiles.yaml and return (ok: bool, names_or_error). + + On success, names_or_error is the list of profile names available + under the 'profiles' key. Read-only — used to validate a requested + profile name BEFORE touching the file. Never writes here. + """ + try: + with open(self._mode_profiles_path, 'r') as f: + data = yaml.safe_load(f) + except FileNotFoundError: + return False, f'profiles file not found: {self._mode_profiles_path}' + except yaml.YAMLError as e: + return False, f'YAML parse error: {e}' + + if not isinstance(data, dict) or 'profiles' not in data: + return False, 'profiles file is empty or missing a profiles: mapping' + + return True, list(data['profiles'].keys()) + + def _set_active_line(self, profile_name: str): + """ + Rewrite ONLY the top-level 'active:' line in mode_profiles.yaml, + in place, leaving every other line — including comments — untouched. + + A yaml.safe_dump() round-trip would silently strip every comment + from the file (PyYAML does not preserve them), which would destroy + the documentation embedded in mode_profiles.yaml. A targeted + regex line-replace avoids that entirely. The pattern only matches + 'active:' anchored at the START of a line (top-level key, no + leading whitespace) — a nested key named 'active' inside a + profile body would be indented and would not match. + + Raises ValueError if no top-level 'active:' line is found, so the + caller can report a clear error instead of silently doing nothing. + """ + with open(self._mode_profiles_path, 'r') as f: + text = f.read() + + new_text, count = re.subn( + r'^active:.*$', f'active: {profile_name}', text, + count=1, flags=re.MULTILINE) + + if count == 0: + raise ValueError( + "no top-level 'active:' key found in profiles file") + + with open(self._mode_profiles_path, 'w') as f: + f.write(new_text) + + def call_mode_reload(self): + """ + Call the /rov/mode/reload Trigger service synchronously and return + (success: bool, message: str). + + Same cross-thread blocking pattern as call_mission_command — see + that method's docstring for why we use a threading.Event rather + than spin_until_future_complete. + """ + if not self._mode_reload_cli.wait_for_service(timeout_sec=SERVICE_WAIT_TIMEOUT_S): + return False, ( + 'mode_profile_loader service /rov/mode/reload unavailable — ' + 'is argonaut.service running?' + ) + + req = Trigger.Request() + future = self._mode_reload_cli.call_async(req) + done_event = threading.Event() + + def _on_done(_fut): + done_event.set() + + future.add_done_callback(_on_done) + + if not done_event.wait(timeout=MODE_RELOAD_TIMEOUT_S): + return False, 'mode reload timed out waiting for response' + + result = future.result() + if result is None: + return False, 'mode reload failed — no response from service' + + return bool(result.success), str(result.message) + + def set_active_profile(self, profile_name: str): + """ + Switch the active mode profile: validate the name, rewrite the + YAML 'active:' line, then trigger mode_profile_loader to reload. + + Returns (success: bool, message: str). + + Validation happens BEFORE the file is touched. mode_profile_loader's + own fail-safe only protects a bad RUNTIME reload (it keeps serving + the last-good profile in memory) — it does NOT protect a bad name + written to disk. If we wrote an unknown profile name to 'active:' + and mode_profile_loader were later restarted (e.g. after a reboot, + with no in-memory last-good profile to fall back on), it would + start up with NO valid profile published at all. Checking here + first prevents that. + """ + ok, names_or_err = self._read_profile_names() + if not ok: + return False, names_or_err + + if profile_name not in names_or_err: + return False, ( + f"'{profile_name}' is not a known profile " + f"(available: {', '.join(names_or_err)})" + ) + + try: + self._set_active_line(profile_name) + except (OSError, ValueError) as e: + return False, f'failed to write profiles file: {e}' + + return self.call_mode_reload() + + def mode_snapshot(self) -> Optional[dict]: + """ + Return the cached resolved mode profile dict, or None if + mode_profile_loader's latched message has not been received yet. + """ + return self._mode_profile + # ------------------------------------------------------------------ # Health snapshot for GET /health # ------------------------------------------------------------------ @@ -437,6 +680,67 @@ def mission_stop(): return {'ok': success, 'message': message} +# ------------------------------------------------------------------ +# GET /mode +# ------------------------------------------------------------------ + +@app.get('/mode') +def get_mode(): + """ + Return the currently active, fully-resolved mode profile. + + Reflects the latched /rov/mode/profile topic — the same resolved + flag set that cockpit_bridge surfaces to the Cockpit data lake as + external/rov-mode, external/rov-hilt, etc., exposed here as a single + JSON object for the setup wizard (Stage 2b) and any other HTTP + consumer. + + Returns ok=False if mode_profile_loader has not published yet — this + should only be possible in the first moments after argonaut.service + starts, since the topic is latched (TRANSIENT_LOCAL) and this node + subscribes at startup, before uvicorn begins serving requests. + """ + if _node is None: + return {'ok': False, 'error': 'ROS2 node not initialised'} + + snap = _node.mode_snapshot() + if snap is None: + return {'ok': False, 'error': 'no mode profile received yet'} + + return {'ok': True, **snap} + + +# ------------------------------------------------------------------ +# POST /mode +# ------------------------------------------------------------------ + +@app.post('/mode') +def set_mode(req: ModeSetRequest): + """ + Switch the active mode profile and trigger mode_profile_loader to + reload it. + + This is the ONLY path that should change mode_profiles.yaml's active + profile at runtime — the setup wizard (Stage 2b) calls this endpoint + rather than writing the file directly, so the validate-before-write + safeguard in RovApiNode.set_active_profile always applies. + + Rejects unknown profile names without writing anything to disk. On + success, mode_profile_loader has already republished /rov/mode/profile + by the time this returns (the loader publishes before replying to the + reload service) — a subsequent GET /mode call will reflect the new + profile once this node's subscription callback has processed it, + which in practice is near-instant on the LAN loopback but is not + awaited synchronously here (consistent with how /mission/start does + not wait for the resulting mission status to update). + """ + if _node is None: + return {'ok': False, 'error': 'ROS2 node not initialised'} + + success, message = _node.set_active_profile(req.profile_name) + return {'ok': success, 'message': message} + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- @@ -458,7 +762,8 @@ def main(args=None): _node = RovApiNode() # Spin the node in a daemon background thread. This services the - # subscriptions (health cache) and the mission service client futures. + # subscriptions (health/mode cache) and the mission/mode service client + # futures. def _spin(): # rclpy.spin blocks, servicing callbacks, until shutdown. try: