feat(api): add GET /mode and POST /mode endpoints to rov_api

This commit is contained in:
Grant 2026-07-08 12:14:10 +02:00
parent 882768af01
commit 9109439707

View File

@ -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: