679 lines
33 KiB
Python
679 lines
33 KiB
Python
"""ROS 2 Navigate Action -> authenticated navigation gateway.
|
|
|
|
The HTTP/session layer uses only the Python standard library. ROS imports are
|
|
lazy so transport behavior can be exercised without a ROS installation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import http.client
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
import os
|
|
import socket
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Mapping
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
_STATES = {'SENDING', 'ACTIVE', 'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'}
|
|
_OUTCOMES = {'COMPLETED', 'FAILED', 'CANCELED', 'TIMED_OUT', 'REJECTED', 'BLOCKED', 'NOT_READY'}
|
|
_CONTROLLER_TERMINALS = {'ARRIVED', 'SUCCEEDED', 'ABORTED', 'PREEMPTED', 'RECALLED', 'REJECTED'}
|
|
_TRACE_FIELDS = ('task_id', 'subtask_id', 'attempt', 'task_revision', 'plan_version',
|
|
'run_id', 'execution_generation')
|
|
_MAX_RESPONSE_BYTES = 65536
|
|
|
|
|
|
class GatewayError(RuntimeError):
|
|
"""A response cannot establish the state of the physical goal."""
|
|
|
|
|
|
def _positive(value: Any, name: str) -> float:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError(f'{name} must be a finite positive number')
|
|
result = float(value)
|
|
if not math.isfinite(result) or result <= 0:
|
|
raise ValueError(f'{name} must be a finite positive number')
|
|
return result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProxyConfig:
|
|
token: str = field(repr=False)
|
|
connect_timeout_sec: float
|
|
read_timeout_sec: float
|
|
request_timeout_sec: float
|
|
poll_interval_sec: float
|
|
readiness_max_age_sec: float
|
|
feedback_silence_timeout_sec: float
|
|
endpoint: str = 'http://127.0.0.1:8766'
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.token, str) or len(self.token) < 16 or any(c.isspace() for c in self.token):
|
|
raise ValueError('A bearer token of at least 16 characters without whitespace is required')
|
|
for name in ('connect_timeout_sec', 'read_timeout_sec', 'request_timeout_sec',
|
|
'poll_interval_sec', 'readiness_max_age_sec', 'feedback_silence_timeout_sec'):
|
|
_positive(getattr(self, name), name)
|
|
parsed = urlsplit(self.endpoint)
|
|
if (parsed.scheme not in {'http', 'https'} or not parsed.hostname or
|
|
parsed.username is not None or parsed.password is not None or
|
|
parsed.path not in {'', '/'} or parsed.query or parsed.fragment):
|
|
raise ValueError('endpoint must be an http(s) origin without credentials or path')
|
|
# DNS lookup has no socket deadline in stdlib. Use an explicit deployment
|
|
# IP; localhost is normalized without invoking the resolver.
|
|
if parsed.hostname != 'localhost':
|
|
try:
|
|
ipaddress.ip_address(parsed.hostname)
|
|
except ValueError as exc:
|
|
raise ValueError('endpoint must use an IP address or localhost') from exc
|
|
try:
|
|
parsed.port
|
|
except ValueError as exc:
|
|
raise ValueError('invalid endpoint port') from exc
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GatewaySnapshot:
|
|
goal_id: str
|
|
status: str
|
|
outcome: str | None
|
|
stop_state: str
|
|
controller_state: str
|
|
message: str
|
|
position_error: float | None
|
|
yaw_error: float | None
|
|
sequence: int
|
|
elapsed: float
|
|
pose_valid: bool = False
|
|
current_pose: dict[str, Any] | None = None
|
|
final_pose: dict[str, Any] | None = None
|
|
stopped_at: float | None = None
|
|
blocked: bool | None = None
|
|
error_code: str = ''
|
|
|
|
@classmethod
|
|
def parse(cls, data: Mapping[str, Any], goal_id: str) -> 'GatewaySnapshot':
|
|
try:
|
|
required = {name: data[name] for name in cls.__dataclass_fields__
|
|
if name not in {'pose_valid', 'current_pose', 'final_pose', 'stopped_at', 'blocked', 'error_code'}}
|
|
snapshot = cls(**required, pose_valid=data.get('pose_valid', False),
|
|
current_pose=validate_observed_pose(data.get('current_pose')),
|
|
final_pose=validate_observed_pose(data.get('final_pose')),
|
|
stopped_at=data.get('stopped_at'), blocked=data.get('blocked'), error_code=data.get('error_code', ''))
|
|
except (TypeError, KeyError) as exc:
|
|
raise GatewayError('Gateway snapshot is missing required fields') from exc
|
|
if snapshot.blocked is not None and type(snapshot.blocked) is not bool:
|
|
raise GatewayError('Gateway blocked must be boolean or unknown')
|
|
if not isinstance(snapshot.error_code, str):
|
|
raise GatewayError('Gateway error_code must be text')
|
|
stop_time_parts = None if snapshot.stopped_at is None else _ros_time_parts(snapshot.stopped_at)
|
|
if type(snapshot.pose_valid) is not bool:
|
|
raise GatewayError('Gateway pose_valid must be boolean')
|
|
if snapshot.pose_valid and snapshot.current_pose is None and snapshot.final_pose is None:
|
|
raise GatewayError('Gateway marks pose valid without an observed pose')
|
|
if snapshot.goal_id != goal_id:
|
|
raise GatewayError('Gateway returned a different goal UUID')
|
|
if snapshot.status not in _STATES or snapshot.outcome not in _OUTCOMES | {None}:
|
|
raise GatewayError('Gateway returned an unknown status or outcome')
|
|
if snapshot.stop_state not in {'UNKNOWN', 'CONFIRMED'}:
|
|
raise GatewayError('Gateway returned an unknown stop state')
|
|
if not isinstance(snapshot.controller_state, str) or not isinstance(snapshot.message, str):
|
|
raise GatewayError('Gateway returned malformed text fields')
|
|
if type(snapshot.sequence) is not int or not 0 <= snapshot.sequence <= 0xffffffff:
|
|
raise GatewayError('Gateway sequence must fit Navigate uint32')
|
|
for name in ('elapsed', 'position_error', 'yaw_error'):
|
|
value = getattr(snapshot, name)
|
|
if value is None and name != 'elapsed':
|
|
continue
|
|
if (isinstance(value, bool) or not isinstance(value, (float, int)) or
|
|
not math.isfinite(value) or (name != 'yaw_error' and value < 0) or
|
|
(name == 'yaw_error' and abs(value) > math.pi)):
|
|
raise GatewayError(f'Gateway {name} is invalid')
|
|
if snapshot.status == 'TERMINAL' and snapshot.outcome is None:
|
|
raise GatewayError('Terminal gateway snapshot has no outcome')
|
|
if snapshot.status == 'TERMINAL' and snapshot.stop_state == 'CONFIRMED':
|
|
if stop_time_parts is None or stop_time_parts == (0, 0):
|
|
raise GatewayError('Confirmed result requires a positive original ROS stop timestamp')
|
|
if snapshot.controller_state not in _CONTROLLER_TERMINALS:
|
|
raise GatewayError('Confirmed result lacks a native controller terminal')
|
|
if snapshot.outcome == 'COMPLETED' and snapshot.controller_state not in {'ARRIVED', 'SUCCEEDED'}:
|
|
raise GatewayError('COMPLETED conflicts with native controller state')
|
|
return snapshot
|
|
|
|
@property
|
|
def is_terminal(self) -> bool:
|
|
return self.status == 'TERMINAL' and self.stop_state == 'CONFIRMED'
|
|
|
|
|
|
def _ros_time_parts(source_seconds: Any) -> tuple[int, int]:
|
|
if (isinstance(source_seconds, bool) or not isinstance(source_seconds, (int, float)) or
|
|
not math.isfinite(source_seconds) or source_seconds < 0 or
|
|
source_seconds >= 2147483648):
|
|
raise GatewayError('Stop timestamp must fit a nonnegative ROS int32 seconds value')
|
|
seconds = math.floor(source_seconds)
|
|
nanoseconds = round((source_seconds - seconds) * 1_000_000_000)
|
|
if nanoseconds >= 1_000_000_000:
|
|
seconds += 1
|
|
nanoseconds -= 1_000_000_000
|
|
if seconds > 2147483647:
|
|
raise GatewayError('Rounded stop timestamp exceeds ROS int32 seconds')
|
|
return seconds, nanoseconds
|
|
|
|
|
|
def assign_ros_time(destination: Any, source_seconds: float) -> None:
|
|
"""Copy the original gateway evidence time, never callback receipt time."""
|
|
destination.sec, destination.nanosec = _ros_time_parts(source_seconds)
|
|
|
|
|
|
def validate_observed_pose(pose: Any) -> dict[str, Any] | None:
|
|
"""Validate an actual map-frame observation without normalizing bad data."""
|
|
if pose is None:
|
|
return None
|
|
if not isinstance(pose, dict) or set(pose) != {'frame_id', 'position', 'orientation'}:
|
|
raise GatewayError('Observed pose must use the gateway map-pose schema')
|
|
if pose['frame_id'] != 'map':
|
|
raise GatewayError('Observed pose must be in the map frame')
|
|
for name, axes in (('position', {'x', 'y', 'z'}), ('orientation', {'x', 'y', 'z', 'w'})):
|
|
coordinates = pose[name]
|
|
if not isinstance(coordinates, dict) or set(coordinates) != axes:
|
|
raise GatewayError('Observed pose coordinates are incomplete')
|
|
if any(isinstance(v, bool) or not isinstance(v, (float, int)) or
|
|
not math.isfinite(v) for v in coordinates.values()):
|
|
raise GatewayError('Observed pose coordinates must be finite')
|
|
if abs(sum(value * value for value in pose['orientation'].values()) - 1.0) > 1e-3:
|
|
raise GatewayError('Observed pose quaternion must be normalized')
|
|
return json.loads(json.dumps(pose, allow_nan=False))
|
|
|
|
|
|
def assign_ros_pose(destination: Any, source: Mapping[str, Any]) -> None:
|
|
"""Copy validated geometry; unavailable source timestamp remains unset."""
|
|
destination.header.frame_id = source['frame_id']
|
|
for name, axes in (('position', ('x', 'y', 'z')), ('orientation', ('x', 'y', 'z', 'w'))):
|
|
for axis in axes:
|
|
setattr(getattr(destination.pose, name), axis, float(source[name][axis]))
|
|
|
|
|
|
def navigation_status(snapshot, enum):
|
|
"""Translate wire outcomes by name; execution and navigation numbers differ."""
|
|
names = {'COMPLETED': 'SUCCEEDED', 'CANCELED': 'CANCELED', 'TIMED_OUT': 'TIMEOUT',
|
|
'BLOCKED': 'BLOCKED', 'NOT_READY': 'NOT_READY', 'REJECTED': 'FAILED', 'FAILED': 'FAILED'}
|
|
if snapshot.outcome in {'FAILED', 'REJECTED', 'NOT_READY'} and snapshot.error_code in {'INPUTS_UNHEALTHY', 'ROBOT_STATE_UNAVAILABLE', 'BACKEND_NOT_CONFIGURED'}:
|
|
return enum.NOT_READY
|
|
return getattr(enum, names[snapshot.outcome])
|
|
|
|
|
|
def assign_navigation_feedback(feedback, snapshot):
|
|
"""A required bool cannot truthfully encode missing blocked telemetry."""
|
|
if snapshot.blocked is None:
|
|
raise GatewayError('blocked telemetry unavailable; stop requested')
|
|
feedback.sequence = snapshot.sequence
|
|
feedback.phase = (feedback.STOPPING if snapshot.status in {'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'}
|
|
else feedback.CHECKING if snapshot.status == 'SENDING'
|
|
else feedback.BLOCKED if snapshot.blocked else feedback.NAVIGATING)
|
|
feedback.blocked = snapshot.blocked
|
|
feedback.current_pose_valid = snapshot.pose_valid and snapshot.current_pose is not None
|
|
if feedback.current_pose_valid:
|
|
assign_ros_pose(feedback.current_pose, snapshot.current_pose)
|
|
feedback.error_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
|
|
if feedback.error_valid:
|
|
feedback.position_error = float(snapshot.position_error)
|
|
feedback.yaw_error = float(snapshot.yaw_error)
|
|
seconds = min(snapshot.elapsed, 2147483647.0)
|
|
feedback.elapsed_time.sec = int(seconds)
|
|
feedback.elapsed_time.nanosec = int((seconds - int(seconds)) * 1e9)
|
|
feedback.message = snapshot.message
|
|
|
|
|
|
class GatewayClient:
|
|
"""One fresh bounded connection per request; no redirect or proxy handling."""
|
|
|
|
def __init__(self, config: ProxyConfig):
|
|
self.config = config
|
|
parsed = urlsplit(config.endpoint)
|
|
self._host = '127.0.0.1' if parsed.hostname == 'localhost' else parsed.hostname
|
|
self._port = parsed.port
|
|
self._connection_type = (http.client.HTTPSConnection if parsed.scheme == 'https'
|
|
else http.client.HTTPConnection)
|
|
|
|
def _request(self, method: str, path: str,
|
|
body: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
raw = None if body is None else json.dumps(body, allow_nan=False, sort_keys=True,
|
|
separators=(',', ':')).encode('utf-8')
|
|
config = self.config
|
|
connection = self._connection_type(self._host, self._port,
|
|
timeout=min(config.connect_timeout_sec, config.request_timeout_sec))
|
|
expired = threading.Event()
|
|
connected_socket = None
|
|
|
|
def expire() -> None:
|
|
expired.set()
|
|
active_socket = connected_socket or connection.sock
|
|
if active_socket is not None:
|
|
try:
|
|
active_socket.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
active_socket.close()
|
|
|
|
deadline = time.monotonic() + config.request_timeout_sec
|
|
watchdog = threading.Timer(config.request_timeout_sec, expire)
|
|
watchdog.daemon = True
|
|
watchdog.start()
|
|
try:
|
|
connection.connect()
|
|
connected_socket = connection.sock
|
|
if expired.is_set() or connection.sock is None:
|
|
raise GatewayError('Gateway request deadline exceeded')
|
|
connection.sock.settimeout(min(config.read_timeout_sec,
|
|
max(0.001, deadline - time.monotonic())))
|
|
connection.request(method, path, raw, {
|
|
'Authorization': f'Bearer {config.token}',
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Connection': 'close',
|
|
})
|
|
response = connection.getresponse()
|
|
# The response may own the socket once Connection: close is seen.
|
|
# The watchdog retains its original reference below through this read.
|
|
payload = response.read(_MAX_RESPONSE_BYTES + 1)
|
|
if expired.is_set() or time.monotonic() > deadline:
|
|
raise GatewayError('Gateway request deadline exceeded')
|
|
if len(payload) > _MAX_RESPONSE_BYTES:
|
|
raise GatewayError('Gateway response exceeds size limit')
|
|
if not 200 <= response.status < 300:
|
|
raise GatewayError(f'Gateway HTTP status {response.status}; stop is unknown')
|
|
data = json.loads(payload)
|
|
if not isinstance(data, dict):
|
|
raise GatewayError('Gateway response must be a JSON object')
|
|
return data
|
|
except (OSError, http.client.HTTPException, ValueError) as exc:
|
|
# Do not log the token, endpoint credentials, or arbitrary response body.
|
|
raise GatewayError(f'Gateway transport/protocol failure: {type(exc).__name__}') from exc
|
|
finally:
|
|
watchdog.cancel()
|
|
connection.close()
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
health = self._request('GET', '/healthz')
|
|
if type(health.get('ready')) is not bool or not isinstance(health.get('reason'), str):
|
|
raise GatewayError('Gateway health response is malformed')
|
|
return health
|
|
|
|
def submit(self, body: Mapping[str, Any]) -> GatewaySnapshot:
|
|
return GatewaySnapshot.parse(self._request('POST', '/v1/goals', body), body['goal_id'])
|
|
|
|
def query(self, goal_id: str) -> GatewaySnapshot:
|
|
_canonical_uuid(goal_id)
|
|
return GatewaySnapshot.parse(self._request('GET', f'/v1/goals/{goal_id}'), goal_id)
|
|
|
|
def cancel(self, goal_id: str) -> GatewaySnapshot:
|
|
_canonical_uuid(goal_id)
|
|
return GatewaySnapshot.parse(self._request('POST', f'/v1/goals/{goal_id}/cancel', {}), goal_id)
|
|
|
|
|
|
def _canonical_uuid(goal_id: str) -> None:
|
|
if not isinstance(goal_id, str) or str(uuid.UUID(goal_id)) != goal_id:
|
|
raise ValueError('goal_id must be a canonical UUID')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionEvent:
|
|
snapshot: GatewaySnapshot | None = None
|
|
error: str | None = None
|
|
terminal: bool = False
|
|
|
|
|
|
class GoalSession:
|
|
"""Background transport for one immutable attempt; cancel means intent only.
|
|
|
|
The caller owns physical resource locking. An error is always stop UNKNOWN.
|
|
Coalescing to the latest event bounds memory while the ROS executor is busy.
|
|
"""
|
|
|
|
def __init__(self, client: GatewayClient, body: Mapping[str, Any]):
|
|
self.client = client
|
|
self._body = json.dumps(body, allow_nan=False, sort_keys=True,
|
|
separators=(',', ':')).encode('utf-8')
|
|
frozen_body = json.loads(self._body)
|
|
self._goal_id = frozen_body['goal_id']
|
|
self._position_tolerance = _positive(frozen_body['position_tolerance'], 'position_tolerance')
|
|
self._yaw_tolerance = _positive(frozen_body['yaw_tolerance'], 'yaw_tolerance')
|
|
_canonical_uuid(self._goal_id)
|
|
self._cancel = threading.Event()
|
|
self._lock = threading.Lock()
|
|
self._event: SessionEvent | None = None
|
|
self._started = False
|
|
|
|
@property
|
|
def goal_id(self) -> str:
|
|
return self._goal_id
|
|
|
|
def start(self) -> None:
|
|
with self._lock:
|
|
if self._started:
|
|
raise RuntimeError('One GoalSession can be started only once')
|
|
self._started = True
|
|
threading.Thread(target=self._run, name=f'nav-{self.goal_id}', daemon=True).start()
|
|
|
|
def request_cancel(self) -> None:
|
|
self._cancel.set()
|
|
|
|
def drain_events(self) -> list[SessionEvent]:
|
|
with self._lock:
|
|
event, self._event = self._event, None
|
|
return [] if event is None else [event]
|
|
|
|
def _publish(self, event: SessionEvent) -> None:
|
|
with self._lock:
|
|
self._event = event
|
|
|
|
def _run(self) -> None:
|
|
cancel_sent = False
|
|
last_sequence = -1
|
|
last_progress = time.monotonic()
|
|
try:
|
|
# A submission acknowledgement, even one containing a terminal
|
|
# snapshot, is not consumed as a physical completion result.
|
|
self.client.submit(json.loads(self._body))
|
|
while True:
|
|
if self._cancel.is_set() and not cancel_sent:
|
|
cancel_sent = True
|
|
self.client.cancel(self.goal_id) # ACK only; never terminal.
|
|
snapshot = self.client.query(self.goal_id)
|
|
if snapshot.sequence > last_sequence:
|
|
if snapshot.is_terminal and snapshot.outcome == 'COMPLETED':
|
|
if (snapshot.position_error is None or snapshot.yaw_error is None or
|
|
snapshot.position_error > self._position_tolerance or
|
|
abs(snapshot.yaw_error) > self._yaw_tolerance):
|
|
raise GatewayError('COMPLETED lacks matching pose tolerance evidence')
|
|
last_sequence = snapshot.sequence
|
|
last_progress = time.monotonic()
|
|
self._publish(SessionEvent(snapshot=snapshot, terminal=snapshot.is_terminal))
|
|
if snapshot.is_terminal:
|
|
return
|
|
if time.monotonic() - last_progress >= self.client.config.feedback_silence_timeout_sec:
|
|
self._cancel.set()
|
|
# Event.wait would spin forever once cancel is set. A private
|
|
# event keeps polling bounded and leaves ROS callbacks unblocked.
|
|
threading.Event().wait(self.client.config.poll_interval_sec)
|
|
except Exception as exc:
|
|
# A lost submit response may conceal an accepted moving goal. Send
|
|
# one best-effort cancel under the same UUID before reporting UNKNOWN.
|
|
if not cancel_sent:
|
|
try:
|
|
self.client.cancel(self.goal_id)
|
|
except Exception:
|
|
pass
|
|
error = str(exc) if isinstance(exc, GatewayError) else type(exc).__name__
|
|
self._publish(SessionEvent(error=error))
|
|
|
|
|
|
def build_goal_body(goal: Any, goal_id: str, map_id: str) -> dict[str, Any]:
|
|
"""Freeze the p13 Navigate goal into the Noetic gateway's explicit schema."""
|
|
_canonical_uuid(goal_id)
|
|
if not isinstance(map_id, str) or not map_id.strip():
|
|
raise ValueError('map_id must be explicitly configured')
|
|
if goal.target_pose.header.frame_id != 'map':
|
|
raise ValueError('Navigate only accepts registered poses in the map frame')
|
|
timeout = goal.timeout.sec + goal.timeout.nanosec / 1e9
|
|
if not 0 <= goal.timeout.nanosec < 1_000_000_000:
|
|
raise ValueError('timeout nanosec is not normalized')
|
|
pose = goal.target_pose.pose
|
|
body = {
|
|
'goal_id': goal_id,
|
|
'trace': {name: getattr(goal.trace, name) for name in _TRACE_FIELDS},
|
|
'map_id': map_id,
|
|
'target_pose': {
|
|
'frame_id': 'map',
|
|
'position': {axis: getattr(pose.position, axis) for axis in ('x', 'y', 'z')},
|
|
'orientation': {axis: getattr(pose.orientation, axis) for axis in ('x', 'y', 'z', 'w')},
|
|
},
|
|
'position_tolerance': _positive(goal.position_tolerance, 'position_tolerance'),
|
|
'yaw_tolerance': _positive(goal.yaw_tolerance, 'yaw_tolerance'),
|
|
'timeout_sec': _positive(timeout, 'timeout'),
|
|
}
|
|
for name in ('task_id', 'subtask_id', 'run_id'):
|
|
if not isinstance(body['trace'][name], str) or not body['trace'][name]:
|
|
raise ValueError('TaskTrace identifiers must be nonempty strings')
|
|
for name in ('attempt', 'task_revision', 'plan_version', 'execution_generation'):
|
|
value = body['trace'][name]
|
|
maximum = 2**64 if name == 'execution_generation' else 2**32
|
|
if type(value) is not int or not 0 < value < maximum:
|
|
raise ValueError('TaskTrace counters must be positive and fit their wire types')
|
|
values = list(body['target_pose']['position'].values()) + list(body['target_pose']['orientation'].values())
|
|
if any(isinstance(v, bool) or not isinstance(v, (float, int)) or not math.isfinite(v) for v in values):
|
|
raise ValueError('target pose must contain finite coordinates')
|
|
orientation = body['target_pose']['orientation']
|
|
scale = max(abs(v) for v in orientation.values())
|
|
if scale == 0:
|
|
raise ValueError('target quaternion must be nonzero')
|
|
scaled = {axis: value / scale for axis, value in orientation.items()}
|
|
norm = math.sqrt(sum(v*v for v in scaled.values()))
|
|
body['target_pose']['orientation'] = {axis: value / norm for axis, value in scaled.items()}
|
|
return json.loads(json.dumps(body, allow_nan=False))
|
|
|
|
|
|
def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
|
|
"""Construct the ROS node; call only after rclpy.init().
|
|
|
|
ROS 2 Humble imports and action runtime require validation on the target.
|
|
"""
|
|
import rclpy
|
|
from rclpy.action import ActionServer, CancelResponse, GoalResponse
|
|
from rclpy.callback_groups import ReentrantCallbackGroup
|
|
from rclpy.node import Node
|
|
from rclpy.task import Future
|
|
from bt_skill_interfaces.action import Navigate
|
|
from bt_skill_interfaces.msg import NavigationResult
|
|
|
|
if not map_id or not action_name:
|
|
raise ValueError('map_id and action_name must be explicitly configured')
|
|
|
|
class NavigateProxy(Node):
|
|
def __init__(self):
|
|
super().__init__('navigation_gateway_proxy')
|
|
self._client = GatewayClient(config)
|
|
self._guard = threading.Lock()
|
|
self._ready = False
|
|
self._health_at = 0.0
|
|
self._health_error_code = ''
|
|
self._reserved = False
|
|
self._stop_unknown = False
|
|
self._record = None
|
|
self._closing = threading.Event()
|
|
self._group = ReentrantCallbackGroup()
|
|
self._server = ActionServer(self, Navigate, action_name,
|
|
execute_callback=self._execute, goal_callback=self._accept,
|
|
cancel_callback=self._cancel_goal, callback_group=self._group)
|
|
self._pump_timer = self.create_timer(config.poll_interval_sec, self._pump)
|
|
self._health_thread = threading.Thread(target=self._health_worker,
|
|
name='nav-readiness', daemon=True)
|
|
self._health_thread.start()
|
|
|
|
def _health_worker(self):
|
|
while not self._closing.is_set():
|
|
try:
|
|
response = self._client.health()
|
|
ready = response['ready'] and response.get('map_id') == map_id
|
|
error_code = response.get('error_code', '')
|
|
if not isinstance(error_code, str): error_code = ''
|
|
except Exception:
|
|
ready = False
|
|
error_code = ''
|
|
with self._guard:
|
|
self._ready, self._health_at = ready, time.monotonic()
|
|
self._health_error_code = error_code
|
|
self._closing.wait(config.poll_interval_sec)
|
|
|
|
def _accept(self, request):
|
|
try:
|
|
# Validation uses a temporary local UUID because ROS supplies the
|
|
# actual UUID with the accepted goal handle. Nothing is sent here.
|
|
build_goal_body(request, '00000000-0000-4000-8000-000000000000', map_id)
|
|
except (AttributeError, TypeError, ValueError):
|
|
return GoalResponse.REJECT
|
|
with self._guard:
|
|
if self._reserved or self._stop_unknown:
|
|
return GoalResponse.REJECT
|
|
self._reserved = True
|
|
return GoalResponse.ACCEPT
|
|
|
|
async def _execute(self, handle):
|
|
with self._guard:
|
|
fresh = time.monotonic() - self._health_at <= config.readiness_max_age_sec
|
|
ready = self._ready and fresh
|
|
if not ready:
|
|
code = self._health_error_code if fresh else ''
|
|
self._reserved = False
|
|
if not ready:
|
|
result = self._unknown_result('navigation backend is not ready; no goal dispatched', code or 'NAV_NOT_READY')
|
|
result.result.status = NavigationResult.NOT_READY
|
|
handle.abort()
|
|
return result
|
|
goal_id = str(uuid.UUID(bytes=bytes(handle.goal_id.uuid)))
|
|
future = Future()
|
|
try:
|
|
session = GoalSession(self._client, build_goal_body(handle.request, goal_id, map_id))
|
|
with self._guard:
|
|
self._record = (handle, session, future)
|
|
if handle.is_cancel_requested:
|
|
session.request_cancel()
|
|
session.start()
|
|
except Exception as exc:
|
|
with self._guard:
|
|
self._stop_unknown = True
|
|
handle.abort()
|
|
return self._unknown_result(type(exc).__name__)
|
|
return await future
|
|
|
|
def _cancel_goal(self, handle):
|
|
with self._guard:
|
|
record = self._record
|
|
if record is not None and bytes(record[0].goal_id.uuid) == bytes(handle.goal_id.uuid):
|
|
record[1].request_cancel()
|
|
# The accepted execute callback also checks is_cancel_requested,
|
|
# covering cancellation before a worker has been registered.
|
|
return CancelResponse.ACCEPT
|
|
|
|
def _unknown_result(self, message, error_code='NAV_GATEWAY_STOP_UNKNOWN'):
|
|
result = Navigate.Result()
|
|
result.result.status = (NavigationResult.NOT_READY if error_code in
|
|
{'INPUTS_UNHEALTHY', 'ROBOT_STATE_UNAVAILABLE', 'BACKEND_NOT_CONFIGURED'} else NavigationResult.FAILED)
|
|
result.result.stop_state = NavigationResult.UNKNOWN
|
|
result.result.error_code = error_code
|
|
result.result.message = message
|
|
result.final_pose_valid = False
|
|
result.final_position_error = math.nan
|
|
result.final_yaw_error = math.nan
|
|
return result
|
|
|
|
def _pump(self):
|
|
with self._guard:
|
|
record = self._record
|
|
if record is None:
|
|
return
|
|
handle, session, future = record
|
|
for event in session.drain_events():
|
|
if event.error is not None:
|
|
with self._guard:
|
|
self._stop_unknown = True
|
|
self._record = None
|
|
handle.abort()
|
|
future.set_result(self._unknown_result(event.error))
|
|
continue
|
|
snapshot = event.snapshot
|
|
if event.terminal:
|
|
result = Navigate.Result()
|
|
result.result.status = navigation_status(snapshot, NavigationResult)
|
|
result.result.stop_state = NavigationResult.CONFIRMED
|
|
result.result.message = snapshot.message
|
|
result.result.error_code = snapshot.error_code or ('' if snapshot.outcome == 'COMPLETED' else 'NAV_' + snapshot.outcome)
|
|
result.result.stop_evidence_ref = f'nav-gateway:{snapshot.goal_id}:sequence:{snapshot.sequence}'
|
|
assign_ros_time(result.result.stopped_at, snapshot.stopped_at)
|
|
result.final_pose_valid = snapshot.pose_valid and snapshot.final_pose is not None
|
|
if result.final_pose_valid:
|
|
assign_ros_pose(result.final_pose, snapshot.final_pose)
|
|
result.final_position_error = math.nan if snapshot.position_error is None else float(snapshot.position_error)
|
|
result.final_yaw_error = math.nan if snapshot.yaw_error is None else float(snapshot.yaw_error)
|
|
if snapshot.outcome == 'COMPLETED':
|
|
handle.succeed()
|
|
elif snapshot.outcome == 'CANCELED' and handle.is_cancel_requested:
|
|
handle.canceled()
|
|
else:
|
|
if snapshot.outcome == 'CANCELED':
|
|
# A remote cancellation has no matching ROS cancel
|
|
# transition. Preserve physical stop, report FAILED.
|
|
result.result.status = NavigationResult.FAILED
|
|
result.result.error_code = 'NAV_REMOTE_CANCELED'
|
|
handle.abort()
|
|
with self._guard:
|
|
self._record = None
|
|
self._reserved = False
|
|
future.set_result(result)
|
|
else:
|
|
feedback = Navigate.Feedback()
|
|
feedback.stamp = self.get_clock().now().to_msg()
|
|
try:
|
|
assign_navigation_feedback(feedback, snapshot)
|
|
except GatewayError as exc:
|
|
session.request_cancel()
|
|
with self._guard:
|
|
self._stop_unknown = True
|
|
self._record = None
|
|
handle.abort()
|
|
future.set_result(self._unknown_result(str(exc), snapshot.error_code or 'NAV_GATEWAY_STOP_UNKNOWN'))
|
|
continue
|
|
handle.publish_feedback(feedback)
|
|
|
|
def destroy_node(self):
|
|
self._closing.set()
|
|
with self._guard:
|
|
record = self._record
|
|
if record is not None:
|
|
record[1].request_cancel()
|
|
self._server.destroy()
|
|
return super().destroy_node()
|
|
|
|
return NavigateProxy()
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--endpoint', default=os.environ.get('NAV_GATEWAY_URL', 'http://127.0.0.1:8766'))
|
|
parser.add_argument('--map-id', default=os.environ.get('NAV_GATEWAY_MAP_ID'))
|
|
parser.add_argument('--action-name', default=os.environ.get('NAV_PROXY_ACTION_NAME'))
|
|
for name in ('connect_timeout_sec', 'read_timeout_sec', 'request_timeout_sec',
|
|
'poll_interval_sec', 'readiness_max_age_sec', 'feedback_silence_timeout_sec'):
|
|
parser.add_argument('--' + name.replace('_', '-'), type=float,
|
|
default=os.environ.get('NAV_PROXY_' + name.upper()))
|
|
args, ros_args = parser.parse_known_args(argv)
|
|
if not args.map_id or not args.action_name:
|
|
parser.error('--map-id and --action-name (or matching environment variables) are required')
|
|
try:
|
|
config = ProxyConfig(token=os.environ.get('NAV_GATEWAY_TOKEN', ''), endpoint=args.endpoint,
|
|
**{name: getattr(args, name) for name in ('connect_timeout_sec', 'read_timeout_sec',
|
|
'request_timeout_sec', 'poll_interval_sec', 'readiness_max_age_sec',
|
|
'feedback_silence_timeout_sec')})
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
import rclpy
|
|
from rclpy.executors import MultiThreadedExecutor
|
|
rclpy.init(args=ros_args)
|
|
node = create_ros_node(config, map_id=args.map_id, action_name=args.action_name)
|
|
executor = MultiThreadedExecutor(num_threads=2)
|
|
executor.add_node(node)
|
|
try:
|
|
executor.spin()
|
|
finally:
|
|
node.destroy_node()
|
|
executor.shutdown()
|
|
rclpy.shutdown()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|