fix: align navigation contract and readiness handling

This commit is contained in:
2026-09-22 14:35:14 +08:00
parent 91bcd92d6b
commit 964d1fde67
21 changed files with 922 additions and 139 deletions
+18 -8
View File
@@ -16,7 +16,7 @@ class NavigationBackend(ABC):
@abstractmethod
def health(self):
"""Return ready, reason, map_id, received_at (local monotonic), source_fresh."""
"""Return readiness plus a timestamped blocked boolean sample; absence is unknown."""
@abstractmethod
def send(self, goal):
@@ -48,9 +48,11 @@ class MockBackend(NavigationBackend):
return (sample.get("source_fresh") is True and isinstance(stamp, (int, float))
and math.isfinite(stamp) and 0 < stamp <= self.source_clock())
def health_sample(self, ready):
def health_sample(self, ready, blocked=False):
self.health_value = {"ready": ready, "reason": "SIMULATION", "map_id": self.map_id,
"received_at": self.clock(), "source_fresh": True, "source_stamp": self.source_clock()}
self.health_value["blocked"] = ({"value": blocked, "received_at": self.clock(),
"source_fresh": True, "source_stamp": self.source_clock()} if type(blocked) is bool else None)
def health(self):
if self.auto_complete_sec is not None: self.health_sample(True)
@@ -59,7 +61,8 @@ class MockBackend(NavigationBackend):
def send(self, goal):
self.send_count += 1
self.goals[goal["goal_id"]] = (copy.deepcopy(goal), self.clock())
self.samples[goal["goal_id"]] = {"controller_state": "REJECTED" if self.send_mode == "REJECTED" else "ACTIVE"}
self.samples[goal["goal_id"]] = {"controller_state": "REJECTED" if self.send_mode == "REJECTED" else "ACTIVE",
"blocked": copy.deepcopy(self.health_value.get("blocked"))}
return self.send_mode
def cancel(self, goal_id):
@@ -67,9 +70,11 @@ class MockBackend(NavigationBackend):
self.cancelled.add(goal_id)
return True # ACK only; tests supply independent telemetry.
def set_snapshot(self, goal_id, state, linear=0.0, angular=0.0, pose=None, source_fresh=True):
def set_snapshot(self, goal_id, state, linear=0.0, angular=0.0, pose=None, source_fresh=True, blocked=False):
self.sequence += 1
self.samples[goal_id] = {"controller_state": state,
"blocked": ({"value": blocked, "received_at": self.clock(), "source_fresh": source_fresh,
"source_stamp": self.source_clock()} if type(blocked) is bool else None),
"odom": {"sequence": self.sequence, "received_at": self.clock(), "source_fresh": source_fresh,
"source_stamp": self.source_clock(), "linear": linear, "angular": angular},
"pose": {"value": copy.deepcopy(pose), "received_at": self.clock(), "source_fresh": source_fresh,
@@ -92,7 +97,7 @@ class Ros1MoveBaseBackend(NavigationBackend):
All ROS endpoint names and freshness limits must be configured. A trusted ROS
safety/health monitor publishes JSON String: ready, map_id, stamp (ROS seconds),
reason; HTTP clients cannot set these values. ROS1 graph must be access controlled.
reason, blocked (explicit bool); HTTP clients cannot set these values. ROS1 graph must be access controlled.
"""
def __init__(self, *, action_name, odom_topic, pose_topic, readiness_topic, map_id,
source_max_age_sec, server_wait_sec):
@@ -176,10 +181,14 @@ class Ros1MoveBaseBackend(NavigationBackend):
import json
try:
data = json.loads(msg.data)
if not isinstance(data, dict): raise ValueError("health payload must be an object")
metadata = self._source_metadata(float(data["stamp"]))
valid = data.get("ready") is True and data.get("map_id") == self.map_id and metadata["source_fresh"]
value = {"ready": valid, "map_id": data.get("map_id"), **metadata,
"reason": str(data.get("reason", "")), "received_at": time.monotonic()}
"reason": str(data.get("reason", "")), "received_at": time.monotonic(),
"error_code": data.get("error_code") if isinstance(data.get("error_code"), str) else ""}
value["blocked"] = ({"value": data["blocked"], **metadata, "received_at": value["received_at"]}
if type(data.get("blocked")) is bool else None)
except (ValueError, TypeError, KeyError):
value = {"ready": False, "map_id": self.map_id, "source_fresh": False,
"reason": "invalid health monitor payload", "received_at": time.monotonic()}
@@ -223,8 +232,9 @@ class Ros1MoveBaseBackend(NavigationBackend):
# get_state also surfaces LOST without waiting for a done callback.
state = self.status_names.get(self.client.get_state(), self.state)
out = {"controller_state": state, "odom": copy.deepcopy(self.odom), "pose": copy.deepcopy(self.pose),
"odom_samples": copy.deepcopy(self.odom_queue)}
for sample in [out["odom"], out["pose"]] + out["odom_samples"]:
"odom_samples": copy.deepcopy(self.odom_queue),
"blocked": copy.deepcopy(self.health_value.get("blocked"))}
for sample in [out["odom"], out["pose"], out["blocked"]] + out["odom_samples"]:
if isinstance(sample, dict): sample["source_fresh"] = self.source_is_fresh(sample)
self.odom_queue.clear()
return out
+22 -4
View File
@@ -55,7 +55,6 @@ def validate_goal(body):
elif not isinstance(value, str) or len(value) > 256: raise GatewayError("invalid trace text")
validate_pose(body["target_pose"])
for key in ("position_tolerance", "yaw_tolerance", "timeout_sec"): number(body[key], key, positive=True)
if body["yaw_tolerance"] > math.pi: raise GatewayError("yaw_tolerance is radians and must be <= pi")
return copy.deepcopy(body)
@@ -130,10 +129,14 @@ class Gateway:
try: health = self.backend.health()
except Exception: health = {}
fresh = self._fresh(health, self.config.readiness_max_age_sec)
ready = health.get("ready") is True and fresh and not self._occupied()
blocked_known = self._blocked_known(health.get("blocked"))
ready = health.get("ready") is True and fresh and blocked_known and not self._occupied()
return {"ready": ready, "map_id": health.get("map_id"), "stamp_monotonic": self.clock(),
"error_code": ("" if self._occupied() else "INPUTS_UNHEALTHY" if not blocked_known
else health.get("error_code", "") if fresh else ""),
"reason": "motion resource occupied or quarantined" if self._occupied() else
(health.get("reason", "") if fresh else "readiness missing or stale")}
("blocked telemetry missing or stale" if not blocked_known else
health.get("reason", "") if fresh else "readiness missing or stale")}
def submit(self, body):
goal = validate_goal(body)
@@ -189,6 +192,10 @@ class Gateway:
stamp = sample.get("received_at")
return isinstance(stamp, (int, float)) and math.isfinite(stamp) and 0 <= self.clock()-stamp <= maximum
def _blocked_known(self, sample):
return (self._fresh(sample, self.config.readiness_max_age_sec) and
type(sample.get("value")) is bool)
def poll(self, goal_id):
with self.lock:
r = self._record(goal_id)
@@ -198,14 +205,25 @@ class Gateway:
if not r["_cancel_sent"]:
try: health = self.backend.health()
except Exception: health = {}
if not self._fresh(health, self.config.readiness_max_age_sec) or health.get("ready") is not True or health.get("map_id") != r["request"]["map_id"]:
if not self._fresh(health, self.config.readiness_max_age_sec) or health.get("ready") is not True or health.get("map_id") != r["request"]["map_id"] or not self._blocked_known(health.get("blocked")):
self.cancel(goal_id, "FAILED")
if (self._fresh(health, self.config.readiness_max_age_sec) and
health.get("error_code") in {"INPUTS_UNHEALTHY", "ROBOT_STATE_UNAVAILABLE", "BACKEND_NOT_CONFIGURED"}):
r["error_code"] = health["error_code"]
if not self._blocked_known(health.get("blocked")): r["error_code"] = "INPUTS_UNHEALTHY"
r["message"] = "readiness lost during execution; stop requested"
try: snapshot = self.backend.snapshot(goal_id)
except Exception: snapshot = {"controller_state": "UNKNOWN"}
state = snapshot.get("controller_state", "UNKNOWN")
r.update(controller_state=state, sequence=r["sequence"]+1, elapsed=max(0, self.clock()-r["_started"]))
r.update(pose_valid=False, current_pose=None)
blocked_sample = snapshot.get("blocked")
r["blocked"] = (blocked_sample["value"] if
self._fresh(blocked_sample, self.config.readiness_max_age_sec) and
type(blocked_sample.get("value")) is bool else None)
if r["blocked"] is None:
if not r["_cancel_sent"]: self.cancel(goal_id, "FAILED")
r["error_code"] = "INPUTS_UNHEALTHY"
pose_sample = snapshot.get("pose")
if self._fresh(pose_sample, self.config.pose_max_age_sec):
try:
+100 -42
View File
@@ -21,7 +21,7 @@ from urllib.parse import urlsplit
_STATES = {'SENDING', 'ACTIVE', 'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'}
_OUTCOMES = {'COMPLETED', 'FAILED', 'CANCELED', 'TIMED_OUT', 'REJECTED'}
_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')
@@ -92,18 +92,24 @@ class GatewaySnapshot:
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'}}
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'))
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')
@@ -191,6 +197,37 @@ def assign_ros_pose(destination: Any, source: Mapping[str, Any]) -> None:
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."""
@@ -396,14 +433,27 @@ def build_goal_body(goal: Any, goal_id: str, map_id: str) -> dict[str, Any]:
'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.orientation_tolerance, 'orientation_tolerance'),
'yaw_tolerance': _positive(goal.yaw_tolerance, 'yaw_tolerance'),
'timeout_sec': _positive(timeout, 'timeout'),
}
if not body['trace']['task_id'] or not body['trace']['subtask_id'] or body['trace']['attempt'] < 1:
raise ValueError('TaskTrace task_id, subtask_id and attempt are required')
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))
@@ -418,7 +468,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
from rclpy.node import Node
from rclpy.task import Future
from bt_skill_interfaces.action import Navigate
from bt_skill_interfaces.msg import ExecutionResult
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')
@@ -430,6 +480,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
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
@@ -448,10 +499,14 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
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):
@@ -462,13 +517,23 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
except (AttributeError, TypeError, ValueError):
return GoalResponse.REJECT
with self._guard:
if (self._reserved or self._stop_unknown or not self._ready or
time.monotonic() - self._health_at > config.readiness_max_age_sec):
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:
@@ -494,14 +559,16 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
# covering cancellation before a worker has been registered.
return CancelResponse.ACCEPT
def _unknown_result(self, message):
def _unknown_result(self, message, error_code='NAV_GATEWAY_STOP_UNKNOWN'):
result = Navigate.Result()
result.result.status = ExecutionResult.FAILED
result.result.stop_state = ExecutionResult.UNKNOWN
result.result.error_code = 'NAV_GATEWAY_STOP_UNKNOWN'
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.pose_valid = False
result.errors_valid = False
result.final_pose_valid = False
result.final_position_error = math.nan
result.final_yaw_error = math.nan
return result
def _pump(self):
@@ -521,19 +588,17 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
snapshot = event.snapshot
if event.terminal:
result = Navigate.Result()
result.result.status = getattr(ExecutionResult, snapshot.outcome)
result.result.stop_state = ExecutionResult.CONFIRMED
result.result.status = navigation_status(snapshot, NavigationResult)
result.result.stop_state = NavigationResult.CONFIRMED
result.result.message = snapshot.message
result.result.error_code = '' if snapshot.outcome == 'COMPLETED' else 'NAV_' + snapshot.outcome
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.pose_valid = snapshot.pose_valid and snapshot.final_pose is not None
if result.pose_valid:
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.errors_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
if result.errors_valid:
result.final_position_error = float(snapshot.position_error)
result.final_orientation_error = float(snapshot.yaw_error)
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:
@@ -542,7 +607,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
if snapshot.outcome == 'CANCELED':
# A remote cancellation has no matching ROS cancel
# transition. Preserve physical stop, report FAILED.
result.result.status = ExecutionResult.FAILED
result.result.status = NavigationResult.FAILED
result.result.error_code = 'NAV_REMOTE_CANCELED'
handle.abort()
with self._guard:
@@ -552,23 +617,16 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
else:
feedback = Navigate.Feedback()
feedback.stamp = self.get_clock().now().to_msg()
feedback.sequence = snapshot.sequence
feedback.phase = (Navigate.Feedback.STOPPING if snapshot.status in
{'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'} else
Navigate.Feedback.CHECKING if snapshot.status == 'SENDING' else
Navigate.Feedback.NAVIGATING)
feedback.pose_valid = snapshot.pose_valid and snapshot.current_pose is not None
if feedback.pose_valid:
assign_ros_pose(feedback.current_pose, snapshot.current_pose)
feedback.blocked_valid = False
feedback.errors_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
if feedback.errors_valid:
feedback.position_error = float(snapshot.position_error)
feedback.orientation_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
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):
+38 -8
View File
@@ -9,6 +9,31 @@ def duration_seconds(value):
if not 0<seconds<=3600:raise ValueError('Duration must be in (0,3600] seconds')
return seconds
def translate_navigation_result(source, target, navigation_enum, execution_enum):
"""Preserve proof and diagnostics while explicitly translating different enums."""
mapping = {navigation_enum.SUCCEEDED: execution_enum.COMPLETED,
navigation_enum.CANCELED: execution_enum.CANCELED,
navigation_enum.TIMEOUT: execution_enum.TIMED_OUT,
navigation_enum.BLOCKED: execution_enum.FAILED,
navigation_enum.NOT_READY: execution_enum.REJECTED,
navigation_enum.FAILED: execution_enum.FAILED}
target.result.status = mapping[source.result.status]
target.result.stop_state = {navigation_enum.UNKNOWN: execution_enum.UNKNOWN,
navigation_enum.CONFIRMED: execution_enum.CONFIRMED}[source.result.stop_state]
for field in ('error_code', 'message', 'stopped_at', 'stop_evidence_ref'):
setattr(target.result, field, getattr(source.result, field))
if not target.result.error_code:
target.result.error_code = {navigation_enum.BLOCKED: 'NAV_BLOCKED',
navigation_enum.NOT_READY: 'NAV_NOT_READY'}.get(source.result.status, '')
target.final_pose = source.final_pose
target.pose_valid = source.final_pose_valid
target.errors_valid = (source.final_pose_valid and
all(math.isfinite(v) for v in (source.final_position_error, source.final_yaw_error)) and
source.final_position_error >= 0 and abs(source.final_yaw_error) <= math.pi)
target.final_position_error = source.final_position_error
target.final_orientation_error = source.final_yaw_error
def main():
import rclpy
from rclpy.node import Node
@@ -16,6 +41,8 @@ def main():
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from bt_skill_interfaces.action import Navigate,NavigateSemantic
from bt_skill_interfaces.msg import NavigationResult,ExecutionResult
from action_msgs.msg import GoalStatus
class Proxy(Node):
def __init__(self):
super().__init__('navigation_semantic_proxy');self.group=ReentrantCallbackGroup();self.lock=threading.Lock();self.reserved=False;self.faulted=False
@@ -38,7 +65,7 @@ def main():
try:
g=h.request;lookup=self.catalog.resolve(g.kind,g.reference,g.registry_version,shelf=g.shelf_id,side=g.side_id,column=g.column_id,tier=g.tier_id)
deadline=self.accepted_at+duration_seconds(g.timeout)
q=Navigate.Goal();q.trace=g.trace;q.position_tolerance=g.position_tolerance;q.orientation_tolerance=g.orientation_tolerance
q=Navigate.Goal();q.trace=g.trace;q.position_tolerance=g.position_tolerance;q.yaw_tolerance=g.orientation_tolerance
p=lookup['pose'];q.target_pose.header.frame_id=p['frame_id'];q.target_pose.header.stamp=self.get_clock().now().to_msg()
for k in ('x','y','z'):setattr(q.target_pose.pose.position,k,float(p[k]))
for k in ('x','y','z','w'):setattr(q.target_pose.pose.orientation,k,float(p['q'+k]))
@@ -46,7 +73,12 @@ def main():
remaining_ns=int((deadline-time.monotonic())*1e9)
if remaining_ns<=0 or h.is_cancel_requested:raise RuntimeError('EXPIRED_BEFORE_DISPATCH')
q.timeout.sec=remaining_ns//1_000_000_000;q.timeout.nanosec=remaining_ns%1_000_000_000
future=self.client.send_goal_async(q);acceptance_deadline=min(deadline,time.monotonic()+3.);sequence=0;last_feedback=0.
def forward_feedback(event):
source=event.feedback
f=NavigateSemantic.Feedback()
for field in ('stamp','sequence','phase','message'):setattr(f,field,getattr(source,field))
h.publish_feedback(f)
future=self.client.send_goal_async(q,feedback_callback=forward_feedback);acceptance_deadline=min(deadline,time.monotonic()+3.)
while not future.done():
if h.is_cancel_requested or time.monotonic()>acceptance_deadline:
def late(f):
@@ -63,20 +95,18 @@ def main():
now=time.monotonic()
if (h.is_cancel_requested or now>=deadline) and cancel_at is None:downstream.cancel_goal_async();cancel_at=now
if cancel_at is not None and now-cancel_at>5:raise RuntimeError('STOP_UNKNOWN')
if now-last_feedback>=.2:
sequence+=1;f=NavigateSemantic.Feedback();f.stamp=self.get_clock().now().to_msg();f.sequence=sequence;f.phase=2 if cancel_at else 1;f.message='awaiting downstream result';h.publish_feedback(f);last_feedback=now
time.sleep(.01)
wrapped=done.result();m=wrapped.result
for field in ('result','final_pose','pose_valid','final_position_error','final_orientation_error','errors_valid'):setattr(result,field,getattr(m,field))
if wrapped.status==4 and m.result.status==0:h.succeed()
elif wrapped.status==5 and h.is_cancel_requested:h.canceled()
translate_navigation_result(m,result,NavigationResult,ExecutionResult)
if wrapped.status==GoalStatus.STATUS_SUCCEEDED and m.result.status==NavigationResult.SUCCEEDED:h.succeed()
elif wrapped.status==GoalStatus.STATUS_CANCELED and m.result.status==NavigationResult.CANCELED and h.is_cancel_requested:h.canceled()
else:h.abort()
except Exception as ex:
self.faulted=True
if downstream is not None and downstream.accepted:
try:downstream.cancel_goal_async()
except Exception:pass
result.result.status=1;result.result.stop_state=0;result.result.error_code=str(ex);h.abort()
result.result.status=ExecutionResult.FAILED;result.result.stop_state=ExecutionResult.UNKNOWN;result.result.error_code=str(ex);h.abort()
finally:
with self.lock:self.reserved=False
return result