feat(api): add GET /mode and POST /mode endpoints to rov_api
This commit is contained in:
parent
882768af01
commit
9109439707
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user