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
|
(Bool, W3 RETURN TO SAFE) (MissionCommand srv) /rov/mission/status
|
||||||
START / ABORT /rov/recording/active
|
START / ABORT /rov/recording/active
|
||||||
/rov/failsafe
|
/rov/failsafe
|
||||||
|
/rov/mode/profile (latched)
|
||||||
|
|
|
||||||
|
call /rov/mode/reload
|
||||||
|
(std_srvs/Trigger, after
|
||||||
|
rewriting mode_profiles.yaml)
|
||||||
|
|
||||||
WHY THIS DESIGN
|
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 /abort -> W3 RETURN TO SAFE (publishes Bool true to /rov/mission/abort)
|
||||||
POST /mission/start -> MissionCommand START
|
POST /mission/start -> MissionCommand START
|
||||||
POST /mission/stop -> MissionCommand ABORT
|
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)
|
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/mission/status : rov_interfaces/msg/MissionStatus (state, progress_percent, ...)
|
||||||
/rov/recording/active: std_msgs/Bool (recording_manager no-go gate)
|
/rov/recording/active: std_msgs/Bool (recording_manager no-go gate)
|
||||||
/rov/failsafe : rov_interfaces/msg/FailsafeStatus (failsafe_state, assessment_state, ...)
|
/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
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# ROS2
|
# ROS2
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import QoSProfile, DurabilityPolicy, ReliabilityPolicy, HistoryPolicy
|
||||||
from std_msgs.msg import Bool
|
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
|
from rov_interfaces.srv import MissionCommand
|
||||||
|
|
||||||
|
# Reading/writing mode_profiles.yaml
|
||||||
|
import yaml
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
# FastAPI
|
# FastAPI
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@ -82,11 +109,17 @@ API_PORT = 8081
|
|||||||
# until then there is no external exposure (Tailscale disabled, no proxy).
|
# until then there is no external exposure (Tailscale disabled, no proxy).
|
||||||
API_HOST = '0.0.0.0'
|
API_HOST = '0.0.0.0'
|
||||||
|
|
||||||
# Timeout (seconds) to wait for the mission_executor service to become
|
# Timeout (seconds) to wait for the mission_executor / mode_profile_loader
|
||||||
# available before failing a mission command request. Kept short so the
|
# service to become available before failing a request. Kept short so the
|
||||||
# widget gets a fast, clear error rather than hanging.
|
# widget gets a fast, clear error rather than hanging.
|
||||||
SERVICE_WAIT_TIMEOUT_S = 2.0
|
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
|
# Pydantic request models
|
||||||
@ -108,6 +141,18 @@ class MissionStartRequest(BaseModel):
|
|||||||
parameters: list[str] = []
|
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
|
# ROS2 node
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -119,12 +164,15 @@ class RovApiNode(Node):
|
|||||||
Owns:
|
Owns:
|
||||||
- a publisher on /rov/mission/abort (Bool) for W3 RETURN TO SAFE
|
- 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/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,
|
- 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
|
All cached values are plain Python types updated inside subscription
|
||||||
callbacks. Reads from the FastAPI thread are atomic (single attribute
|
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):
|
def __init__(self):
|
||||||
@ -148,6 +196,26 @@ class RovApiNode(Node):
|
|||||||
# Latest failsafe state integer (FailsafeStatus.*); -1 = no data
|
# Latest failsafe state integer (FailsafeStatus.*); -1 = no data
|
||||||
self._failsafe_state: int = -1
|
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)
|
# Publisher — /rov/mission/abort (W3 RETURN TO SAFE)
|
||||||
# Publishing Bool(true) sets flag_manual_abort in failsafe_monitor,
|
# Publishing Bool(true) sets flag_manual_abort in failsafe_monitor,
|
||||||
@ -167,7 +235,16 @@ class RovApiNode(Node):
|
|||||||
MissionCommand, '/rov/mission/command')
|
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)
|
# Mission status from mission_executor (2 Hz)
|
||||||
@ -185,6 +262,21 @@ class RovApiNode(Node):
|
|||||||
FailsafeStatus, '/rov/failsafe',
|
FailsafeStatus, '/rov/failsafe',
|
||||||
self._failsafe_cb, 10)
|
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)
|
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."""
|
"""Cache the latest failsafe state for /health."""
|
||||||
self._failsafe_state = int(msg.failsafe_state)
|
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
|
# Actions called from the FastAPI thread
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -281,6 +396,134 @@ class RovApiNode(Node):
|
|||||||
|
|
||||||
return bool(result.success), str(result.message)
|
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
|
# Health snapshot for GET /health
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -437,6 +680,67 @@ def mission_stop():
|
|||||||
return {'ok': success, 'message': message}
|
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
|
# Entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -458,7 +762,8 @@ def main(args=None):
|
|||||||
_node = RovApiNode()
|
_node = RovApiNode()
|
||||||
|
|
||||||
# Spin the node in a daemon background thread. This services the
|
# 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():
|
def _spin():
|
||||||
# rclpy.spin blocks, servicing callbacks, until shutdown.
|
# rclpy.spin blocks, servicing callbacks, until shutdown.
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user