#!/usr/bin/env python3 from __future__ import annotations import argparse import json import math import os import shlex import signal import subprocess import threading import time import uuid import yaml from copy import deepcopy from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import parse_qs, urlparse from urllib.request import Request, urlopen import rclpy from action_msgs.msg import GoalStatus from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist from nav2_msgs.action import NavigateToPose from nav_msgs.msg import OccupancyGrid from rclpy.action import ActionClient from rclpy.duration import Duration from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy from rclpy.time import Time from sensor_msgs.msg import LaserScan, PointCloud2 from std_srvs.srv import Empty from tf2_ros import Buffer, TransformException, TransformListener from unitree_go.msg import LowState, SportModeState def yaw_to_quaternion(yaw_rad: float) -> tuple[float, float, float, float]: half = yaw_rad * 0.5 return 0.0, 0.0, math.sin(half), math.cos(half) def quaternion_to_yaw_deg(x: float, y: float, z: float, w: float) -> float: siny_cosp = 2.0 * (w * z + x * y) cosy_cosp = 1.0 - 2.0 * (y * y + z * z) return math.degrees(math.atan2(siny_cosp, cosy_cosp)) def now_s() -> float: return time.time() def read_json_body(handler: SimpleHTTPRequestHandler) -> dict[str, Any]: length = int(handler.headers.get("Content-Length") or "0") if length <= 0: return {} raw = handler.rfile.read(length) try: value = json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: raise ValueError(f"Invalid JSON body: {exc}") from exc if not isinstance(value, dict): raise ValueError("JSON body must be an object") return value def require_float(data: dict[str, Any], key: str) -> float: try: value = float(data[key]) except KeyError as exc: raise ValueError(f"Missing field: {key}") from exc except (TypeError, ValueError) as exc: raise ValueError(f"Field must be numeric: {key}") from exc if not math.isfinite(value): raise ValueError(f"Field must be finite: {key}") return value def status_name(status: int) -> str: return { GoalStatus.STATUS_UNKNOWN: "unknown", GoalStatus.STATUS_ACCEPTED: "accepted", GoalStatus.STATUS_EXECUTING: "executing", GoalStatus.STATUS_CANCELING: "canceling", GoalStatus.STATUS_SUCCEEDED: "succeeded", GoalStatus.STATUS_CANCELED: "canceled", GoalStatus.STATUS_ABORTED: "aborted", }.get(status, f"status_{status}") def normalize_yaw_deg(value: float) -> float: return (value + 180.0) % 360.0 - 180.0 def env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "y", "on"} def truncate_text(value: str, limit: int = 1600) -> str: if len(value) <= limit: return value return value[:limit] + "...[truncated]" def truncate_lines(lines: list[str], limit: int = 8) -> list[str]: if len(lines) <= limit: return lines return lines[-limit:] INSPECTION_ACTIVE_STATES = {"starting", "navigating", "arrived", "correcting", "segmenting", "dwelling", "canceling"} GOAL_TERMINAL_STATES = {"succeeded", "failed", "rejected", "canceled", "aborted", "unknown"} GOAL_INACTIVE_STATES = GOAL_TERMINAL_STATES | {"stopped", "stop_failed"} ROBOT_REQUIRED_PUBLISHER_TOPICS = ( "/lf/sportmodestate", "/lf/lowstate", "/utlidar/robot_pose", "/utlidar/cloud_deskewed", ) ROBOT_COMMAND_TOPIC = "/api/sport/request" LOCALIZATION_CONFIRM_MAX_AGE_S = 300.0 LOCALIZATION_CONFIRM_MAX_DRIFT_M = 0.3 LOCALIZATION_CONFIRM_MAX_YAW_DRIFT_DEG = 20.0 ROBOT_SOURCE_FRESHNESS_S = 3.0 NAV_SCAN_TOPIC = os.environ.get("GO2_NAV_SCAN_TOPIC", "/go2/scan").strip() or "/go2/scan" NAV_HEALTH_SCAN_FRESHNESS_S = float(os.environ.get("GO2_NAV_HEALTH_SCAN_FRESHNESS_S", "1.0")) NAV_HEALTH_TF_FRESHNESS_S = float(os.environ.get("GO2_NAV_HEALTH_TF_FRESHNESS_S", "1.0")) NAV_HEALTH_TF_STAMP_FRESHNESS_S = float( os.environ.get("GO2_NAV_HEALTH_TF_STAMP_FRESHNESS_S", str(max(NAV_HEALTH_TF_FRESHNESS_S, 1.0))) ) NAV_HEALTH_POSE_FRESHNESS_S = float(os.environ.get("GO2_NAV_HEALTH_POSE_FRESHNESS_S", "1.0")) NAV_HEALTH_MIN_VALID_SCAN_RANGES = int(os.environ.get("GO2_NAV_HEALTH_MIN_VALID_SCAN_RANGES", "3")) NAV_HEALTH_MAX_AMCL_TF_DELTA_M = float(os.environ.get("GO2_NAV_HEALTH_MAX_AMCL_TF_DELTA_M", "0.35")) NAV_HEALTH_MAX_AMCL_TF_YAW_DELTA_DEG = float(os.environ.get("GO2_NAV_HEALTH_MAX_AMCL_TF_YAW_DELTA_DEG", "25.0")) INSPECTION_REQUIRE_AMCL_CONSISTENCY = env_bool("GO2_INSPECTION_REQUIRE_AMCL_CONSISTENCY", False) INSPECTION_LOCALIZATION_REFRESH_TIMEOUT_S = float(os.environ.get("GO2_INSPECTION_LOCALIZATION_REFRESH_TIMEOUT_S", "2.0")) INSPECTION_LOCALIZATION_REFRESH_SETTLE_S = float(os.environ.get("GO2_INSPECTION_LOCALIZATION_REFRESH_SETTLE_S", "0.8")) INSPECTION_POSITION_TOLERANCE_M = float(os.environ.get("GO2_INSPECTION_POSITION_TOLERANCE_M", "0.25")) INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M = float( os.environ.get("GO2_INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M", "0.35") ) INSPECTION_NAV_SUCCESS_NEAR_GOAL_TOLERANCE_M = float( os.environ.get("GO2_INSPECTION_NAV_SUCCESS_NEAR_GOAL_TOLERANCE_M", "0.60") ) INSPECTION_POSITION_CORRECTION_THRESHOLD_M = float(os.environ.get("GO2_INSPECTION_POSITION_CORRECTION_THRESHOLD_M", "0.20")) INSPECTION_YAW_TOLERANCE_DEG = float(os.environ.get("GO2_INSPECTION_YAW_TOLERANCE_DEG", "6.0")) INSPECTION_POSE_FRESHNESS_S = float(os.environ.get("GO2_INSPECTION_POSE_FRESHNESS_S", "5.0")) INSPECTION_MAX_LINEAR_SPEED_MPS = float(os.environ.get("GO2_INSPECTION_MAX_LINEAR_SPEED_MPS", "0.15")) INSPECTION_MAX_YAW_RATE_DEG_S = float(os.environ.get("GO2_INSPECTION_MAX_YAW_RATE_DEG_S", "15.0")) INSPECTION_ARRIVAL_STABLE_HOLD_S = float(os.environ.get("GO2_INSPECTION_ARRIVAL_STABLE_HOLD_S", "0.2")) INSPECTION_DWELL_DURATION_S = float(os.environ.get("GO2_INSPECTION_DWELL_DURATION_S", "3.0")) INSPECTION_YAW_ALIGN_TIMEOUT_S = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_TIMEOUT_S", "12.0")) INSPECTION_YAW_ALIGN_MIN_RATE_RAD_S = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_MIN_RATE_RAD_S", "0.35")) INSPECTION_YAW_ALIGN_MAX_RATE_RAD_S = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_MAX_RATE_RAD_S", "0.55")) INSPECTION_YAW_ALIGN_SLOWDOWN_DEG = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_SLOWDOWN_DEG", "35.0")) INSPECTION_YAW_ALIGN_ARC_LINEAR_MPS = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_ARC_LINEAR_MPS", "0.12")) INSPECTION_YAW_ALIGN_ARC_ENABLE_DEG = float(os.environ.get("GO2_INSPECTION_YAW_ALIGN_ARC_ENABLE_DEG", "10.0")) REALTIME_SEGMENT_DEFAULT_URL = "http://192.168.0.199:5000/realtime-segment" REALTIME_SEGMENT_DEFAULT_IMAGE_FIELD = "image" REALTIME_SEGMENT_FRAME_MAX_AGE_S = 5.0 INSPECTION_NAV_BT_XML = os.environ.get("GO2_INSPECTION_NAV_BT_XML", "/home/limerly/go2_robot_system/install/go2_navigation2/share/go2_navigation2/behavior_trees/inspection_position_only.xml") def safe_float_env(name: str, default: float) -> float: try: return float(os.environ.get(name, str(default))) except (TypeError, ValueError): return default def parse_json_response(text: str) -> Any: if not text: return None try: return json.loads(text) except json.JSONDecodeError: return None def extract_response_error(payload: Any, fallback: str) -> str: if isinstance(payload, dict): for key in ("error", "message", "detail", "reason"): value = payload.get(key) if value: return str(value) return fallback def encode_multipart_form_data(fields: dict[str, str], file_field: str, filename: str, content_type: str, data: bytes) -> tuple[bytes, str]: boundary = f"----go2-dashboard-{uuid.uuid4().hex}" chunks: list[bytes] = [] for key, value in fields.items(): chunks.extend( [ f"--{boundary}\r\n".encode("utf-8"), f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8"), str(value).encode("utf-8"), b"\r\n", ] ) chunks.extend( [ f"--{boundary}\r\n".encode("utf-8"), f'Content-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'.encode("utf-8"), f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"), data, b"\r\n", f"--{boundary}--\r\n".encode("utf-8"), ] ) return b"".join(chunks), f"multipart/form-data; boundary={boundary}" class PosePipeline: """Small pose-processing seam reserved for later localization optimization.""" DEFAULT_CONFIG = { "version": 1, "algorithm": "passthrough_with_offsets", "source_priority": ["tf:map->base_link", "amcl:/amcl_pose"], "max_source_age_s": 2.0, "offset": {"x": 0.0, "y": 0.0, "yaw_deg": 0.0}, "smoothing": {"enabled": False, "alpha": 0.65}, } def __init__(self, config_path: Path) -> None: self.config_path = config_path self._lock = threading.RLock() self._last_pose: dict[str, Any] | None = None self.config = self._load_config() def _load_config(self) -> dict[str, Any]: config = deepcopy(self.DEFAULT_CONFIG) if self.config_path.exists(): try: user_config = json.loads(self.config_path.read_text(encoding="utf-8")) if isinstance(user_config, dict): config.update(user_config) if isinstance(user_config.get("offset"), dict): config["offset"] = {**self.DEFAULT_CONFIG["offset"], **user_config["offset"]} if isinstance(user_config.get("smoothing"), dict): config["smoothing"] = {**self.DEFAULT_CONFIG["smoothing"], **user_config["smoothing"]} except Exception: pass return config def priority(self) -> list[str]: value = self.config.get("source_priority", self.DEFAULT_CONFIG["source_priority"]) return [str(item) for item in value] if isinstance(value, list) else list(self.DEFAULT_CONFIG["source_priority"]) def max_source_age_s(self) -> float: try: return float(self.config.get("max_source_age_s", self.DEFAULT_CONFIG["max_source_age_s"])) except (TypeError, ValueError): return float(self.DEFAULT_CONFIG["max_source_age_s"]) def process(self, raw_pose: dict[str, Any]) -> dict[str, Any]: with self._lock: pose = deepcopy(raw_pose) offset = self.config.get("offset") if isinstance(self.config.get("offset"), dict) else {} pose["x"] = float(pose["x"]) + float(offset.get("x", 0.0) or 0.0) pose["y"] = float(pose["y"]) + float(offset.get("y", 0.0) or 0.0) pose["yaw_deg"] = normalize_yaw_deg(float(pose["yaw_deg"]) + float(offset.get("yaw_deg", 0.0) or 0.0)) smoothing = self.config.get("smoothing") if isinstance(self.config.get("smoothing"), dict) else {} if smoothing.get("enabled") and self._last_pose: alpha = max(0.0, min(1.0, float(smoothing.get("alpha", 0.65)))) previous = self._last_pose pose["x"] = alpha * pose["x"] + (1.0 - alpha) * float(previous["x"]) pose["y"] = alpha * pose["y"] + (1.0 - alpha) * float(previous["y"]) yaw_delta = normalize_yaw_deg(float(pose["yaw_deg"]) - float(previous["yaw_deg"])) pose["yaw_deg"] = normalize_yaw_deg(float(previous["yaw_deg"]) + alpha * yaw_delta) pose["raw_source"] = raw_pose.get("source") pose["source"] = f"pose_pipeline:{self.config.get('algorithm', 'passthrough_with_offsets')}" pose["pipeline_config"] = { "algorithm": self.config.get("algorithm", "passthrough_with_offsets"), "offset": deepcopy(self.config.get("offset", {})), "smoothing": deepcopy(self.config.get("smoothing", {})), } self._last_pose = deepcopy(pose) return pose class WaypointActionHook: def __init__(self, config_path: Path) -> None: self.config_path = config_path self._status = { "enabled": False, "state": "disabled", "reason": "inspection_dwell_mode", "updated_at_unix_s": now_s(), "config_path": str(self.config_path), } def enabled(self) -> bool: return False def status(self) -> dict[str, Any]: payload = deepcopy(self._status) payload["enabled"] = False payload["config"] = { "mode": "disabled", "required": False, "reason": "\u5de1\u68c0\u4e3b\u6d41\u7a0b\u53ea\u505a\u5bfc\u822a\u5230\u70b9\u3001\u5230\u70b9\u786e\u8ba4\u548c 3 \u79d2\u505c\u7559\uff0c\u4e0d\u518d\u6267\u884c waypoint_action\u3002", } return payload class OpenSourceNavNode(Node): def __init__( self, map_yaml: Path, repo_root: Path, nav_log_dir: Path, pose_config: Path, waypoint_action_config: Path, ) -> None: super().__init__("go2_dashboard") self.map_yaml = map_yaml self.repo_root = repo_root self.nav_log_dir = nav_log_dir self.route_path = repo_root / "go2_dashboard" / "data" / "inspection_routes" / "default.json" self.patrol_route_path = repo_root / "config" / "ros2_patrol_routes" / "default.yaml" self.pose_pipeline = PosePipeline(pose_config) self.waypoint_action = WaypointActionHook(waypoint_action_config) self._lock = threading.RLock() self._map: dict[str, Any] | None = None self._fallback_map: dict[str, Any] | None = None self._pose: dict[str, Any] | None = None self._raw_poses: dict[str, dict[str, Any]] = {} self._robot_source_last_seen: dict[str, float] = {} self._scan_health: dict[str, Any] = {"topic": NAV_SCAN_TOPIC, "received_at_unix_s": None, "state": "waiting"} self._amcl_covariance: dict[str, Any] = {"position_uncertainty_m": None, "yaw_uncertainty_deg": None, "raw_xx": None, "raw_yy": None, "raw_yaw": None, "updated_at_unix_s": None} self._tf_health: dict[str, Any] = {"state": "waiting", "source": "tf:map->base_link"} self._goal: dict[str, Any] = {"state": "idle", "updated_at_unix_s": now_s()} self._current_goal_handle: Any | None = None self._current_goal_sequence: int | None = None self._goal_sequence = 0 self._inspection_run_sequence = 0 self._inspection_run: dict[str, Any] = {"state": "idle", "updated_at_unix_s": now_s()} self._localization_confirmation: dict[str, Any] | None = None self.camera_sampler: CameraSampler | None = None map_qos = QoSProfile( history=HistoryPolicy.KEEP_LAST, depth=1, reliability=ReliabilityPolicy.RELIABLE, durability=DurabilityPolicy.TRANSIENT_LOCAL, ) self.create_subscription(OccupancyGrid, "/map", self._map_callback, map_qos) self.create_subscription(PoseWithCovarianceStamped, "/amcl_pose", self._pose_callback, 10) self.create_subscription(LaserScan, NAV_SCAN_TOPIC, lambda msg: self._scan_callback(msg, NAV_SCAN_TOPIC), 10) if NAV_SCAN_TOPIC != "/scan": self.create_subscription(LaserScan, "/scan", lambda msg: self._scan_callback(msg, "/scan"), 10) self.create_subscription(SportModeState, "/lf/sportmodestate", lambda msg: self._robot_source_callback("/lf/sportmodestate"), 10) self.create_subscription(LowState, "/lf/lowstate", lambda msg: self._robot_source_callback("/lf/lowstate"), 10) self.create_subscription(PoseStamped, "/utlidar/robot_pose", lambda msg: self._robot_source_callback("/utlidar/robot_pose"), 10) self.create_subscription(PointCloud2, "/utlidar/cloud_deskewed", lambda msg: self._robot_source_callback("/utlidar/cloud_deskewed"), 10) self.initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10) self.cmd_vel_pub = self.create_publisher(Twist, "/cmd_vel", 10) self.cmd_vel_nav_pub = self.create_publisher(Twist, "/cmd_vel_nav", 10) self.nav_client = ActionClient(self, NavigateToPose, "/navigate_to_pose") self.amcl_nomotion_client = self.create_client(Empty, "/request_nomotion") self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) self.create_timer(0.1, self._tf_pose_timer) def _invalidate_localization_confirmation(self, reason: str) -> None: with self._lock: self._localization_confirmation = { "state": "needs_confirmation", "reason": reason, "updated_at_unix_s": now_s(), } def _robot_source_callback(self, topic: str) -> None: with self._lock: self._robot_source_last_seen[topic] = now_s() def _map_callback(self, msg: OccupancyGrid) -> None: yaw_deg = quaternion_to_yaw_deg( msg.info.origin.orientation.x, msg.info.origin.orientation.y, msg.info.origin.orientation.z, msg.info.origin.orientation.w, ) payload = { "name": "maps/current", "revision": map_file_revision(self.map_yaml), "frame_id": msg.header.frame_id or "map", "resolution": float(msg.info.resolution), "width": int(msg.info.width), "height": int(msg.info.height), "origin": { "x": float(msg.info.origin.position.x), "y": float(msg.info.origin.position.y), "yaw_deg": yaw_deg, }, "cells": [int(value) for value in msg.data], "source": "ros:/map", "received_at_unix_s": now_s(), } with self._lock: self._map = payload def _scan_callback(self, msg: LaserScan, topic: str = NAV_SCAN_TOPIC) -> None: finite_ranges = [float(value) for value in msg.ranges if math.isfinite(float(value))] min_range_m = min(finite_ranges) if finite_ranges else None max_range_m = max(finite_ranges) if finite_ranges else None stamp_unix_s = float(msg.header.stamp.sec) + float(msg.header.stamp.nanosec) / 1e9 payload = { "topic": topic, "frame_id": msg.header.frame_id or "", "stamp_unix_s": stamp_unix_s, "received_at_unix_s": now_s(), "range_count": len(msg.ranges), "valid_range_count": len(finite_ranges), "min_range_m": min_range_m, "max_range_m": max_range_m, "range_min_m": float(msg.range_min), "range_max_m": float(msg.range_max), "state": "receiving", } with self._lock: self._scan_health = payload def _pose_callback(self, msg: PoseWithCovarianceStamped) -> None: pose = msg.pose.pose payload = { "x": float(pose.position.x), "y": float(pose.position.y), "z": float(pose.position.z), "yaw_deg": quaternion_to_yaw_deg( pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w, ), "frame_id": msg.header.frame_id or "map", "stamp_unix_s": float(msg.header.stamp.sec) + float(msg.header.stamp.nanosec) / 1e9, "received_at_unix_s": now_s(), "source": "amcl:/amcl_pose", } cov = msg.pose.covariance cov_xx = float(cov[0]) cov_yy = float(cov[7]) cov_yaw = float(cov[35]) position_uncertainty = math.sqrt(cov_xx + cov_yy) yaw_uncertainty_deg = math.degrees(math.sqrt(max(0.0, cov_yaw))) with self._lock: self._amcl_covariance = { "position_uncertainty_m": round(position_uncertainty, 4), "yaw_uncertainty_deg": round(yaw_uncertainty_deg, 2), "raw_xx": round(cov_xx, 6), "raw_yy": round(cov_yy, 6), "raw_yaw": round(cov_yaw, 6), "updated_at_unix_s": now_s(), } self._update_pose(payload) def _tf_pose_timer(self) -> None: transform = None source = None for target_frame in ("base_link",): try: transform = self.tf_buffer.lookup_transform("map", target_frame, Time(), timeout=Duration(seconds=0.02)) source = f"tf:map->{target_frame}" break except TransformException: continue if transform is None or source is None: with self._lock: self._tf_health = { "state": "missing", "source": "tf:map->base_link", "updated_at_unix_s": now_s(), "reason": "map->base_link transform unavailable", } return translation = transform.transform.translation rotation = transform.transform.rotation tf_stamp = float(transform.header.stamp.sec) + float(transform.header.stamp.nanosec) / 1e9 received_at = now_s() with self._lock: self._tf_health = { "state": "receiving", "source": source, "frame_id": "map", "child_frame_id": transform.child_frame_id, "stamp_unix_s": tf_stamp, "received_at_unix_s": received_at, } payload = { "x": float(translation.x), "y": float(translation.y), "z": float(translation.z), "yaw_deg": quaternion_to_yaw_deg(rotation.x, rotation.y, rotation.z, rotation.w), "frame_id": "map", "stamp_unix_s": tf_stamp, "received_at_unix_s": received_at, "source": source, } self._update_pose(payload) def _update_pose(self, payload: dict[str, Any]) -> None: with self._lock: source = str(payload.get("source") or "unknown") self._raw_poses[source] = deepcopy(payload) selected = self._select_raw_pose_locked() self._pose = self.pose_pipeline.process(selected) if selected else None def _pose_fresh_enough(self, pose: dict[str, Any] | None) -> bool: if not pose: return False received_age_s = max(0.0, now_s() - float(pose.get("received_at_unix_s", 0.0))) stamp_age_s = None if pose.get("stamp_unix_s"): stamp_age_s = max(0.0, now_s() - float(pose.get("stamp_unix_s", 0.0))) if stamp_age_s is None: return received_age_s <= self.pose_pipeline.max_source_age_s() return received_age_s <= self.pose_pipeline.max_source_age_s() and stamp_age_s <= max(self.pose_pipeline.max_source_age_s(), 0.5) def _select_raw_pose_locked(self) -> dict[str, Any] | None: cutoff = now_s() - self.pose_pipeline.max_source_age_s() for source in self.pose_pipeline.priority(): pose = self._raw_poses.get(source) if pose and float(pose.get("received_at_unix_s", 0.0)) >= cutoff: return deepcopy(pose) fresh = [ pose for pose in self._raw_poses.values() if float(pose.get("received_at_unix_s", 0.0)) >= cutoff ] if not fresh: return None return deepcopy(max(fresh, key=lambda item: float(item.get("received_at_unix_s", 0.0)))) def _navigation_health_state(self, pose: dict[str, Any] | None) -> dict[str, Any]: current_time = now_s() with self._lock: scan = deepcopy(self._scan_health) tf_health = deepcopy(self._tf_health) raw_poses = deepcopy(self._raw_poses) checks: list[dict[str, Any]] = [] blocking_reasons: list[str] = [] def add_check(check_id: str, label: str, ok: bool, detail: str, reason: str | None = None, blocking: bool = True) -> None: checks.append({ "id": check_id, "label": label, "ok": bool(ok), "reason": None if ok else (reason or detail), "detail": detail, "blocking": blocking, }) if blocking and not ok: blocking_reasons.append(reason or detail) scan_received_at = scan.get("received_at_unix_s") scan_age_s = None if scan_received_at is None else max(0.0, current_time - float(scan_received_at)) scan_stamp_age_s = None if scan.get("stamp_unix_s"): scan_stamp_age_s = max(0.0, current_time - float(scan["stamp_unix_s"])) valid_ranges = int(scan.get("valid_range_count") or 0) scan_fresh = scan_age_s is not None and scan_age_s <= NAV_HEALTH_SCAN_FRESHNESS_S scan_stamp_fresh = scan_stamp_age_s is None or scan_stamp_age_s <= max(NAV_HEALTH_SCAN_FRESHNESS_S, 1.0) scan_enough = valid_ranges >= NAV_HEALTH_MIN_VALID_SCAN_RANGES # A LaserScan with mostly +inf ranges can be valid in open space. Treat # low finite-return counts as a warning while keeping freshness/stamp # checks blocking; Nav2 costmaps still consume the full scan message. scan_ok = scan_fresh and scan_stamp_fresh scan_topic = str(scan.get("topic") or NAV_SCAN_TOPIC) scan_detail = f"{scan_topic} age={scan_age_s:.2f}s valid={valid_ranges}" if scan_age_s is not None else f"{scan_topic} has not been received" if scan_stamp_age_s is not None: scan_detail += f" stamp_age={scan_stamp_age_s:.2f}s" scan_reason = None if scan_age_s is None: scan_reason = f"{scan_topic} has not been received; local obstacle avoidance is unreliable" elif not scan_fresh: scan_reason = f"{scan_topic} data is stale {scan_age_s:.2f}s; local obstacle avoidance is unreliable" elif not scan_stamp_fresh: scan_reason = f"{scan_topic} stamp is stale {scan_stamp_age_s:.2f}s; TF alignment is unreliable" elif not scan_enough: scan_reason = f"{scan_topic} has too few finite ranges ({valid_ranges}); obstacle detection is unreliable" add_check("scan", scan_topic, scan_ok, scan_detail, scan_reason) if scan_ok and not scan_enough: checks[-1]["ok"] = False checks[-1]["reason"] = scan_reason checks[-1]["blocking"] = False tf_received_at = tf_health.get("received_at_unix_s") or tf_health.get("updated_at_unix_s") tf_age_s = None if tf_received_at is None else max(0.0, current_time - float(tf_received_at)) tf_stamp_age_s = None if tf_health.get("stamp_unix_s"): tf_stamp_age_s = max(0.0, current_time - float(tf_health["stamp_unix_s"])) tf_fresh = tf_age_s is not None and tf_age_s <= NAV_HEALTH_TF_FRESHNESS_S and tf_health.get("state") == "receiving" tf_stamp_fresh = tf_stamp_age_s is None or tf_stamp_age_s <= NAV_HEALTH_TF_STAMP_FRESHNESS_S tf_ok = tf_fresh and tf_stamp_fresh tf_detail = f"{tf_health.get('source') or 'tf'} age={tf_age_s:.2f}s" if tf_age_s is not None else "map->base TF has not been received" if tf_stamp_age_s is not None: tf_detail += f" stamp_age={tf_stamp_age_s:.2f}s" tf_reason = None if tf_age_s is None: tf_reason = "\u672a\u6536\u5230 map->base TF\uff0c\u65e0\u6cd5\u786e\u8ba4\u5f53\u524d\u4f4d\u59ff" elif tf_health.get("state") != "receiving": tf_reason = "map->base TF \u4e0d\u53ef\u7528\uff0c\u5bfc\u822a\u5750\u6807\u94fe\u8def\u5f02\u5e38" elif not tf_fresh: tf_reason = f"map->base TF \u6570\u636e\u8fc7\u671f {tf_age_s:.2f}s\uff0c\u5f53\u524d\u4f4d\u7f6e\u4e0d\u53ef\u4fe1" elif not tf_stamp_fresh: tf_reason = f"map->base TF \u65f6\u95f4\u6233\u8fc7\u671f {tf_stamp_age_s:.2f}s\uff0c\u5b9a\u4f4d\u94fe\u8def\u4e0d\u53ef\u9760" add_check("tf", "TF map->base", tf_ok, tf_detail, tf_reason) pose_age_s = None pose_stamp_age_s = None pose_fresh = False if pose and pose.get("received_at_unix_s"): pose_age_s = max(0.0, current_time - float(pose["received_at_unix_s"])) pose_fresh = pose_age_s <= NAV_HEALTH_POSE_FRESHNESS_S and self._pose_fresh_enough(pose) if pose.get("stamp_unix_s"): pose_stamp_age_s = max(0.0, current_time - float(pose["stamp_unix_s"])) pose_detail = f"pose age={pose_age_s:.2f}s" if pose_age_s is not None else "current map pose unavailable" if pose_stamp_age_s is not None: pose_detail += f" stamp_age={pose_stamp_age_s:.2f}s" pose_reason = None if not pose: pose_reason = "\u5f53\u524d map \u4f4d\u59ff\u4e0d\u53ef\u7528\uff0c\u8bf7\u91cd\u65b0\u8bbe\u7f6e\u521d\u59cb\u4f4d\u59ff" elif not pose_fresh: pose_reason = f"\u5f53\u524d map \u4f4d\u59ff\u8fc7\u671f {pose_age_s:.2f}s\uff0c\u8bf7\u91cd\u65b0\u8bbe\u7f6e\u521d\u59cb\u4f4d\u59ff" add_check("pose", "\u5f53\u524d\u4f4d\u59ff", pose_fresh, pose_detail, pose_reason) tf_pose = None for source in ("tf:map->base_link",): if raw_poses.get(source): tf_pose = raw_poses[source] break amcl_pose = raw_poses.get("amcl:/amcl_pose") amcl_age_s = None amcl_stamp_age_s = None amcl_fresh = False if amcl_pose and amcl_pose.get("received_at_unix_s"): amcl_age_s = max(0.0, current_time - float(amcl_pose["received_at_unix_s"])) amcl_fresh = amcl_age_s <= NAV_HEALTH_POSE_FRESHNESS_S and self._pose_fresh_enough(amcl_pose) if amcl_pose.get("stamp_unix_s"): amcl_stamp_age_s = max(0.0, current_time - float(amcl_pose["stamp_unix_s"])) consistency_ok = True consistency_detail = "waiting for AMCL and TF pose" consistency_reason = None consistency_blocking = True pose_delta_m = None yaw_delta_deg = None if amcl_pose and tf_pose: pose_delta_m = math.hypot(float(amcl_pose["x"]) - float(tf_pose["x"]), float(amcl_pose["y"]) - float(tf_pose["y"])) yaw_delta_deg = abs(normalize_yaw_deg(float(amcl_pose["yaw_deg"]) - float(tf_pose["yaw_deg"]))) consistency_detail = f"AMCL/TF dpos={pose_delta_m:.3f}m dyaw={yaw_delta_deg:.1f}deg" if amcl_fresh: consistency_ok = pose_delta_m <= NAV_HEALTH_MAX_AMCL_TF_DELTA_M and yaw_delta_deg <= NAV_HEALTH_MAX_AMCL_TF_YAW_DELTA_DEG else: consistency_ok = True consistency_blocking = False consistency_detail += ";/amcl_pose 已停更,按新鲜 TF 位姿继续" if amcl_fresh and not consistency_ok: consistency_reason = f"AMCL \u4e0e TF \u4f4d\u59ff\u4e0d\u4e00\u81f4\uff1a\u8ddd\u79bb {pose_delta_m:.3f}m\uff0c\u671d\u5411 {yaw_delta_deg:.1f}deg" tf_localization_ok = bool(scan_ok and tf_ok and pose_fresh and tf_pose) amcl_consistent_with_tf = bool(amcl_pose and tf_pose and consistency_ok) amcl_can_be_non_blocking = ( tf_localization_ok and not INSPECTION_REQUIRE_AMCL_CONSISTENCY and (not amcl_pose or not amcl_fresh or amcl_consistent_with_tf or not consistency_blocking) ) amcl_detail = f"/amcl_pose age={amcl_age_s:.2f}s" if amcl_age_s is not None else "/amcl_pose has not been received" if amcl_stamp_age_s is not None: amcl_detail += f" stamp_age={amcl_stamp_age_s:.2f}s" if not amcl_fresh and amcl_can_be_non_blocking: amcl_detail += ";scan/TF/当前位姿新鲜,按 TF 定位链路继续" amcl_reason = None if not amcl_pose: amcl_reason = "未收到 /amcl_pose,但 scan/TF/当前位姿新鲜,按 TF 定位链路继续" if amcl_can_be_non_blocking else "\u672a\u6536\u5230 /amcl_pose\uff0cAMCL \u5b9a\u4f4d\u4e0d\u53ef\u7528" elif not amcl_fresh: amcl_reason = ( f"/amcl_pose 已停更 {amcl_age_s:.2f}s,但 scan/TF/当前位姿新鲜,按 TF 定位链路继续" if amcl_can_be_non_blocking else f"/amcl_pose \u5df2\u505c\u66f4 {amcl_age_s:.2f}s\uff0cAMCL \u6ca1\u6709\u6210\u529f\u5173\u8054 /scan \u548c TF\uff0c\u7981\u6b62\u5de1\u68c0" ) add_check("amcl_pose", "AMCL 定位更新", amcl_fresh or amcl_can_be_non_blocking, amcl_detail, amcl_reason, blocking=not amcl_can_be_non_blocking) add_check("pose_consistency", "AMCL/TF \u4e00\u81f4\u6027", consistency_ok, consistency_detail, consistency_reason, blocking=consistency_blocking) ok = not blocking_reasons return { "ok": ok, "state": "ok" if ok else "blocked", "summary": "scan/TF/pose fresh" if ok else blocking_reasons[0], "blocking_reasons": blocking_reasons, "checks": checks, "scan": scan, "tf": tf_health, "pose_age_s": pose_age_s, "pose_stamp_age_s": pose_stamp_age_s, "amcl_pose_age_s": amcl_age_s, "amcl_pose_stamp_age_s": amcl_stamp_age_s, "amcl_tf_delta_m": pose_delta_m, "amcl_tf_yaw_delta_deg": yaw_delta_deg, "limits": { "scan_freshness_s": NAV_HEALTH_SCAN_FRESHNESS_S, "tf_freshness_s": NAV_HEALTH_TF_FRESHNESS_S, "tf_stamp_freshness_s": NAV_HEALTH_TF_STAMP_FRESHNESS_S, "pose_freshness_s": NAV_HEALTH_POSE_FRESHNESS_S, "min_valid_scan_ranges": NAV_HEALTH_MIN_VALID_SCAN_RANGES, "max_amcl_tf_delta_m": NAV_HEALTH_MAX_AMCL_TF_DELTA_M, "max_amcl_tf_yaw_delta_deg": NAV_HEALTH_MAX_AMCL_TF_YAW_DELTA_DEG, }, } def _robot_ros_link_state(self) -> dict[str, Any]: publisher_counts: dict[str, int] = {} source_ages_s: dict[str, float | None] = {} stale_sources: list[str] = [] current_time = now_s() with self._lock: last_seen = deepcopy(self._robot_source_last_seen) for topic in ROBOT_REQUIRED_PUBLISHER_TOPICS: count = len(self.get_publishers_info_by_topic(topic)) publisher_counts[topic] = count seen_at = last_seen.get(topic) age_s = None if seen_at is None else max(0.0, current_time - float(seen_at)) source_ages_s[topic] = age_s if age_s is None or age_s > ROBOT_SOURCE_FRESHNESS_S: stale_sources.append(topic) command_subscriber_count = len(self.get_subscriptions_info_by_topic(ROBOT_COMMAND_TOPIC)) sources_fresh = not stale_sources command_ready = command_subscriber_count > 0 ready = sources_fresh and command_ready return { "ready": ready, "sources_fresh": sources_fresh, "required_publishers": publisher_counts, "source_ages_s": source_ages_s, "source_freshness_s": ROBOT_SOURCE_FRESHNESS_S, "stale_sources": stale_sources, "command_topic": ROBOT_COMMAND_TOPIC, "command_subscriber_count": command_subscriber_count, "command_ready": command_ready, "missing_command_subscriber": not command_ready, } def _latest_nav_log_path(self) -> Path | None: try: files = [path for path in self.nav_log_dir.glob("*.log") if path.is_file()] except Exception: return None if not files: return None return max(files, key=lambda path: path.stat().st_mtime) def _nav_log_tail_lines(self, max_bytes: int = 240_000) -> tuple[Path | None, list[str]]: path = self._latest_nav_log_path() if path is None: return None, [] try: with path.open("rb") as handle: handle.seek(0, os.SEEK_END) size = handle.tell() handle.seek(max(0, size - max_bytes), os.SEEK_SET) data = handle.read() except Exception: return path, [] text = data.decode("utf-8", errors="replace") return path, text.splitlines() @staticmethod def _pick_relevant_nav_log_lines(lines: list[str]) -> list[str]: patterns = ( "Failed to make progress", "No valid trajectories", "Goal failed", "Aborting handle", "Message Filter dropping message", "Extrapolation Error", "extrapolation into", "Failed to transform", "Timed out waiting", "Collision", "controller_server", "planner_server", "bt_navigator", ) matched = [line.strip() for line in lines if any(pattern in line for pattern in patterns)] return truncate_lines(matched, 12) @staticmethod def _classify_nav_log_text(text: str) -> tuple[str, str]: lower = text.lower() if "failed to make progress" in lower: return ( "nav2_controller_stalled", "Nav2 控制器无法继续推进。通常是局部轨迹全无效、障碍层/代价地图阻挡、目标附近姿态不稳定,或 TF 时间戳导致局部规划拿不到可用轨迹。", ) if "no valid trajectories" in lower: return ( "nav2_no_valid_trajectory", "Nav2 局部规划器没有找到可用轨迹。优先检查局部代价地图、障碍层、机器人足迹膨胀半径和目标点周围是否被占用。", ) if "message filter dropping message" in lower or "extrapolation" in lower or "failed to transform" in lower: return ( "tf_time_sync", "TF/传感器时间戳不同步或 TF 缓存对不上,Nav2 正在丢弃 scan/costmap 消息。优先检查 map->odom、odom->base_link、/scan 时间戳和系统时间。", ) if "goal failed" in lower or "aborting handle" in lower: return ( "nav2_goal_aborted", "Nav2 action 已中止,但日志没有给出更细原因。请查看控制器、规划器和 costmap 日志。", ) return ("nav2_goal_failed", "Nav2 目标没有成功完成。请查看诊断控制台里的日志摘要。") def _diagnose_navigation_failure(self, goal_state: str, error: str | None = None) -> dict[str, Any]: with self._lock: pose = deepcopy(self._pose) navigation_health = self._navigation_health_state(pose) robot_ros = self._robot_ros_link_state() path, lines = self._nav_log_tail_lines() relevant_lines = self._pick_relevant_nav_log_lines(lines) log_text = "\n".join(relevant_lines) category, message = self._classify_nav_log_text(log_text) if not robot_ros.get("ready"): category = "robot_ros_link" stale = ", ".join(robot_ros.get("stale_sources") or []) if stale: message = f"机器人 ROS 源数据不新鲜:{stale}。优先检查网线/DDS/驱动话题。" elif robot_ros.get("missing_command_subscriber"): message = f"缺少 {robot_ros.get('command_topic') or ROBOT_COMMAND_TOPIC} 指令订阅者,速度指令可能没有进入机器狗控制桥。" elif not navigation_health.get("ok"): category = "navigation_health" message = navigation_health.get("summary") or "scan、TF 或当前位姿不新鲜。" next_step_by_category = { "nav2_controller_stalled": "先看 RViz 局部代价地图和 /scan 是否把目标前方封死,再看 TF 时间戳丢帧;必要时重设初始位姿后只跑第一个点。", "nav2_no_valid_trajectory": "检查目标点附近是否在障碍/膨胀区,确认 /scan 点云、局部 costmap 和机器人 footprint 没有把通路堵死。", "tf_time_sync": "检查 /scan stamp、map->odom、odom->base_link、base_link->utlidar_lidar;如果持续出现 extrapolation,先修时间戳/TF 发布链。", "robot_ros_link": "检查网线、ens160、DDS、机器人源话题和 /api/sport/request 订阅者。", "navigation_health": "先恢复 /scan、TF、当前位姿新鲜度,再重新确认定位。", "nav2_goal_aborted": "查看 Nav2 日志中的 controller_server、planner_server、bt_navigator 摘要。", "nav2_goal_failed": "查看 Nav2 日志摘要,并结合 scan/TF/pose 健康状态定位。", } return { "category": category, "message": message, "next_step": next_step_by_category.get(category, "查看诊断控制台和 Nav2 日志摘要。"), "goal_state": goal_state, "raw_error": error, "nav_log_path": str(path) if path else None, "nav_log_lines": relevant_lines, "navigation_health_summary": navigation_health.get("summary"), "navigation_health_ok": bool(navigation_health.get("ok")), "robot_ros_ready": bool(robot_ros.get("ready")), "robot_ros_stale_sources": robot_ros.get("stale_sources") or [], } def _localization_state( self, pose: dict[str, Any] | None, map_payload: dict[str, Any] | None, nav_launch_pid: int | None, ) -> dict[str, Any]: current_time = now_s() pose_age_s = None pose_fresh = False if pose and pose.get("received_at_unix_s"): pose_age_s = max(0.0, current_time - float(pose["received_at_unix_s"])) pose_fresh = self._pose_fresh_enough(pose) with self._lock: confirmation = deepcopy(self._localization_confirmation) state = "confirmed" reason = "" distance_from_confirmed_m = None yaw_delta_from_confirmed_deg = None confirmed_age_s = None if not pose: state = "pose_unavailable" reason = "No current pose in map frame; set 2D Pose Estimate first" elif not pose_fresh: state = "pose_stale" reason = "Current map pose is stale; set 2D Pose Estimate again" elif not map_payload: state = "map_unavailable" reason = "Map is unavailable" elif not confirmation or confirmation.get("state") != "confirmed": state = "needs_confirmation" reason = "Operator must confirm the displayed robot position and yaw before inspection" else: confirmed_age_s = max(0.0, current_time - float(confirmation.get("confirmed_at_unix_s", 0.0))) confirmed_pose = confirmation.get("pose") if isinstance(confirmation.get("pose"), dict) else None if confirmation.get("map_yaml") != str(self.map_yaml): state = "needs_confirmation" reason = "Map changed after localization confirmation" elif int(confirmation.get("map_revision") or -1) != int(map_payload.get("revision") or -2): state = "needs_confirmation" reason = "Map revision changed after localization confirmation" elif confirmation.get("nav_launch_pid") != nav_launch_pid: state = "needs_confirmation" reason = "Nav2 was restarted after localization confirmation" elif confirmed_age_s > LOCALIZATION_CONFIRM_MAX_AGE_S: state = "needs_confirmation" reason = "Localization confirmation expired" elif not confirmed_pose: state = "needs_confirmation" reason = "Localization confirmation has no pose snapshot" else: distance_from_confirmed_m = math.hypot( float(pose["x"]) - float(confirmed_pose["x"]), float(pose["y"]) - float(confirmed_pose["y"]), ) yaw_delta_from_confirmed_deg = abs(normalize_yaw_deg(float(pose["yaw_deg"]) - float(confirmed_pose["yaw_deg"]))) if distance_from_confirmed_m > LOCALIZATION_CONFIRM_MAX_DRIFT_M: state = "needs_confirmation" reason = "Robot moved after localization confirmation" elif yaw_delta_from_confirmed_deg > LOCALIZATION_CONFIRM_MAX_YAW_DRIFT_DEG: state = "needs_confirmation" reason = "Robot yaw changed after localization confirmation" return { "state": state, "confirmed": state == "confirmed", "reason": reason, "pose_fresh": pose_fresh, "pose_age_s": pose_age_s, "confirmation": confirmation, "confirmed_age_s": confirmed_age_s, "distance_from_confirmed_m": distance_from_confirmed_m, "yaw_delta_from_confirmed_deg": yaw_delta_from_confirmed_deg, "limits": { "confirmation_max_age_s": LOCALIZATION_CONFIRM_MAX_AGE_S, "confirmation_max_drift_m": LOCALIZATION_CONFIRM_MAX_DRIFT_M, "confirmation_max_yaw_drift_deg": LOCALIZATION_CONFIRM_MAX_YAW_DRIFT_DEG, }, } def _preflight_state( self, pose: dict[str, Any] | None, map_payload: dict[str, Any] | None, nav_launch_pid: int | None, nav2_action_ready: bool, ) -> dict[str, Any]: robot_ros = self._robot_ros_link_state() navigation_health = self._navigation_health_state(pose) localization = self._localization_state(pose, map_payload, nav_launch_pid) try: self._assert_obstacle_avoidance_config() except Exception as exc: obstacle_ok = False obstacle_detail = str(exc) else: obstacle_ok = True obstacle_detail = "Nav2 obstacle and inflation layers enabled" robot_source_detail = ( "fresh source topics" if robot_ros["sources_fresh"] else "Missing fresh robot source data: " + ", ".join(robot_ros["stale_sources"]) ) command_detail = ( f"{robot_ros['command_subscriber_count']} subscriber(s) on {ROBOT_COMMAND_TOPIC}" if robot_ros["command_ready"] else f"Missing robot command subscriber: {ROBOT_COMMAND_TOPIC}" ) checks = [ { "id": "robot_sources", "label": "robot source data", "ok": bool(robot_ros["sources_fresh"]), "reason": None if robot_ros["sources_fresh"] else robot_source_detail, "detail": robot_source_detail, "next_step": "Check the Go2 ROS 2/DDS link and VM network interface.", "blocking": True, }, { "id": "robot_command_subscriber", "label": "robot command link", "ok": bool(robot_ros["command_ready"]), "reason": None if robot_ros["command_ready"] else command_detail, "detail": command_detail, "next_step": "Start the twist bridge/driver and confirm it subscribes to /api/sport/request.", "blocking": False, }, { "id": "nav2_launch", "label": "Nav2 launch", "ok": bool(nav_launch_pid), "reason": None if nav_launch_pid else "Nav2 launch is not running", "detail": f"pid {nav_launch_pid}" if nav_launch_pid else "not running", "next_step": "Click start navigation or run scripts/go2_os_start_nav.sh.", "blocking": True, }, { "id": "nav2_action", "label": "Nav2 action", "ok": bool(nav2_action_ready), "reason": None if nav2_action_ready else "Nav2 /navigate_to_pose action server is not ready", "detail": "action server ready" if nav2_action_ready else "waiting for /navigate_to_pose", "next_step": "Wait for the Nav2 lifecycle nodes to become active; check nav logs if this persists.", "blocking": True, }, { "id": "nav2_obstacle_avoidance", "label": "Nav2 obstacle avoidance", "ok": obstacle_ok, "reason": None if obstacle_ok else obstacle_detail, "detail": obstacle_detail, "next_step": "Check src/go2_navigation2/config/nav2_params.yaml obstacle and inflation layers.", "blocking": False, }, { "id": "navigation_health", "label": "scan/TF/pose freshness", "ok": bool(navigation_health["ok"]), "reason": None if navigation_health["ok"] else navigation_health["summary"], "detail": navigation_health["summary"], "next_step": "Check /scan, map->base TF, AMCL pose, and robot ROS freshness before sending goals.", "blocking": False, }, { "id": "localization", "label": "localization confirmation", "ok": bool(localization["confirmed"]), "reason": None if localization["confirmed"] else localization["reason"], "detail": "operator confirmed" if localization["confirmed"] else localization["reason"], "next_step": "Set 2D Pose Estimate, then confirm the displayed position and yaw.", "blocking": False, }, ] blocking_reasons = [ str(check["reason"]) for check in checks if check.get("blocking") and not check.get("ok") and check.get("reason") ] return { "navigation_ready": bool(nav_launch_pid and nav2_action_ready and robot_ros["ready"]), "inspection_start_ready": not blocking_reasons, "blocking_reasons": blocking_reasons, "checks": checks, "robot_ros": robot_ros, "navigation_health": navigation_health, "localization": localization, } def _assert_inspection_preflight(self, pose: dict[str, Any] | None, map_payload: dict[str, Any]) -> None: nav_launch_pid = self._nav_launch_pid() nav2_action_ready = bool(self.nav_client.server_is_ready()) preflight = self._preflight_state(pose, map_payload, nav_launch_pid, nav2_action_ready) if not preflight["inspection_start_ready"]: self.get_logger().warning("Preflight warnings (non-blocking): " + "; ".join(preflight["blocking_reasons"])) def _request_amcl_nomotion_update(self) -> dict[str, Any]: service_name = "/request_nomotion" result: dict[str, Any] = { "service": service_name, "requested_at_unix_s": now_s(), "available": False, "called": False, "ok": False, } if not self.amcl_nomotion_client.wait_for_service(timeout_sec=0.2): result["reason"] = f"{service_name} service unavailable" return result result["available"] = True future = self.amcl_nomotion_client.call_async(Empty.Request()) deadline = now_s() + max(0.1, INSPECTION_LOCALIZATION_REFRESH_TIMEOUT_S) while now_s() < deadline: if future.done(): break time.sleep(0.02) if not future.done(): result["reason"] = f"{service_name} timeout" return result try: future.result() except Exception as exc: result["reason"] = f"{service_name} failed: {exc}" return result result["called"] = True result["ok"] = True return result def _wait_for_navigation_health_ok(self, timeout_s: float) -> tuple[bool, dict[str, Any]]: deadline = now_s() + max(0.1, timeout_s) latest: dict[str, Any] = {"ok": False, "summary": "navigation health not checked"} while now_s() < deadline: with self._lock: pose = deepcopy(self._pose) latest = self._navigation_health_state(pose) if latest.get("ok"): return True, latest time.sleep(0.1) with self._lock: pose = deepcopy(self._pose) latest = self._navigation_health_state(pose) return bool(latest.get("ok")), latest def _refresh_localization_for_inspection(self, run_id: int, stage: str, waypoint_label: str) -> tuple[bool, dict[str, Any]]: self._publish_zero_velocity_burst() request_result = self._request_amcl_nomotion_update() if INSPECTION_LOCALIZATION_REFRESH_SETTLE_S > 0.0: time.sleep(INSPECTION_LOCALIZATION_REFRESH_SETTLE_S) ok, health = self._wait_for_navigation_health_ok(INSPECTION_LOCALIZATION_REFRESH_TIMEOUT_S) result = { "stage": stage, "waypoint_label": waypoint_label, "request_nomotion_update": request_result, "navigation_health": health, "ok": ok, "updated_at_unix_s": now_s(), } self._patch_inspection_run( run_id, { "localization_refresh": deepcopy(result), "status_text": f"{waypoint_label}: localization refreshed" if ok else f"{waypoint_label}: localization refresh failed", "updated_at_unix_s": now_s(), }, ) return ok, result @staticmethod def _localization_refresh_error_detail(result: dict[str, Any]) -> dict[str, Any]: health = result.get("navigation_health") if isinstance(result.get("navigation_health"), dict) else {} request_result = result.get("request_nomotion_update") if isinstance(result.get("request_nomotion_update"), dict) else {} summary = health.get("summary") or request_result.get("reason") or "AMCL/TF/scan localization refresh failed" return { "category": "localization_refresh_failed", "message": f"Localization remains unreliable after laser refresh: {summary}", "next_step": "Reset the initial pose, then verify /go2/scan, map->odom, odom->base_link, base_link->utlidar_lidar, and AMCL/TF consistency before continuing inspection.", "navigation_health_summary": health.get("summary"), "navigation_health_ok": bool(health.get("ok")), "request_nomotion_update": request_result, "amcl_tf_delta_m": health.get("amcl_tf_delta_m"), "amcl_tf_yaw_delta_deg": health.get("amcl_tf_yaw_delta_deg"), } def state(self) -> dict[str, Any]: with self._lock: map_payload = deepcopy(self._map) if self._map else self._load_fallback_map() pose = deepcopy(self._pose) raw_poses = deepcopy(self._raw_poses) goal = deepcopy(self._goal) inspection_run = self._inspection_run_view(deepcopy(self._inspection_run)) map_meta = None if map_payload: map_meta = { key: map_payload[key] for key in ("name", "revision", "frame_id", "resolution", "width", "height", "origin", "source") if key in map_payload } nodes = sorted(set(self.get_node_names())) pose_age_s = None if pose and pose.get("received_at_unix_s"): pose_age_s = max(0.0, now_s() - float(pose["received_at_unix_s"])) inspection_route = self._inspection_route_summary(map_payload) nav_launch_pid = self._nav_launch_pid() nav2_action_ready = bool(self.nav_client.server_is_ready()) preflight = self._preflight_state(pose, map_payload, nav_launch_pid, nav2_action_ready) return { "name": "openSourceDashboardStates/current", "server_time_unix_s": now_s(), "mode": "go2_nav2", "repo_root": str(self.repo_root), "map_yaml": str(self.map_yaml), "ros": { "available": True, "nodes": nodes, "nav2_action_ready": nav2_action_ready, "nav2_launch_running": nav_launch_pid is not None, "nav2_launch_pid": nav_launch_pid, }, "network": { "requested_ros_iface": os.environ.get("OPEN_SOURCE_DASHBOARD_REQUESTED_ROS_IFACE"), "ros_iface": os.environ.get("OPEN_SOURCE_DASHBOARD_ROS_IFACE"), "dds_peers": os.environ.get("OPEN_SOURCE_DASHBOARD_DDS_PEERS"), "warning": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_WARNING"), "policy_role": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_POLICY"), "primary_iface": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_PRIMARY_IFACE"), "fallback_iface": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_FALLBACK_IFACE"), "selected_iface": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_SELECTED_IFACE"), "selection_reason": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_SELECTION_REASON"), "blocking_error": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_BLOCKING_ERROR"), "fallback_active": os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_FALLBACK_ACTIVE") == "1", }, "map": map_meta, "pose": pose, "amcl_covariance": self._amcl_covariance, "pose_age_s": pose_age_s, "poses": { "selected_source": pose.get("raw_source") if pose else None, "raw": raw_poses, }, "pose_pipeline": { "config": deepcopy(self.pose_pipeline.config), "config_path": str(self.pose_pipeline.config_path), }, "robot_ros": preflight["robot_ros"], "navigation_health": preflight["navigation_health"], "localization": preflight["localization"], "preflight": { "navigation_ready": preflight["navigation_ready"], "inspection_start_ready": preflight["inspection_start_ready"], "blocking_reasons": preflight["blocking_reasons"], "checks": preflight["checks"], }, "waypoint_action": self.waypoint_action.status(), "goal": goal, "inspection_route": inspection_route, "inspection_run": inspection_run, } def _empty_inspection_route(self) -> dict[str, Any]: return { "name": "inspectionRoutes/default", "display_name": "默认巡检路线", "map_yaml": str(self.map_yaml), "map_revision": None, "waypoints": [], "updated_at_unix_s": None, } def load_inspection_route(self) -> dict[str, Any]: if not self.route_path.exists(): return self._empty_inspection_route() try: route = json.loads(self.route_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise RuntimeError(f"Inspection route file is invalid JSON: {exc}") from exc if not isinstance(route, dict): raise RuntimeError("Inspection route file must contain a JSON object") merged = self._empty_inspection_route() merged.update(route) if not isinstance(merged.get("waypoints"), list): merged["waypoints"] = [] return merged def _inspection_route_summary(self, map_payload: dict[str, Any] | None) -> dict[str, Any]: try: route = self.load_inspection_route() except Exception as exc: return { "name": "inspectionRoutes/default", "state": "error", "error": str(exc), "route_path": str(self.route_path), } waypoints = [item for item in route.get("waypoints", []) if isinstance(item, dict)] enabled = [item for item in waypoints if item.get("enabled", True)] current_revision = map_payload.get("revision") if map_payload else None route_revision = route.get("map_revision") map_match = ( route.get("map_yaml") == str(self.map_yaml) and route_revision is not None and current_revision is not None and int(route_revision) == int(current_revision) ) return { "name": route.get("name", "inspectionRoutes/default"), "display_name": route.get("display_name", "默认巡检路线"), "route_path": str(self.route_path), "waypoint_count": len(waypoints), "enabled_waypoint_count": len(enabled), "map_yaml": route.get("map_yaml"), "map_revision": route_revision, "current_map_revision": current_revision, "map_match": map_match, "updated_at_unix_s": route.get("updated_at_unix_s"), } def patch_inspection_route(self, body: dict[str, Any], update_mask: str) -> dict[str, Any]: fields = {item.strip() for item in update_mask.split(",") if item.strip()} if not fields: raise ValueError("Missing update_mask") unsupported = fields - {"display_name", "waypoints"} if unsupported: raise ValueError(f"Unsupported update_mask field(s): {', '.join(sorted(unsupported))}") route = self.load_inspection_route() if "display_name" in fields: display_name = str(body.get("display_name", route.get("display_name", "默认巡检路线"))).strip() route["display_name"] = display_name or "默认巡检路线" if "waypoints" in fields: raw_waypoints = body.get("waypoints") if not isinstance(raw_waypoints, list): raise ValueError("waypoints must be an array") route["waypoints"] = self._normalize_waypoints(raw_waypoints) map_payload = self.map_payload() route["name"] = "inspectionRoutes/default" route["map_yaml"] = str(self.map_yaml) route["map_revision"] = int(map_payload["revision"]) route["updated_at_unix_s"] = now_s() self._write_inspection_route(route) return route def _normalize_waypoints(self, raw_waypoints: list[Any]) -> list[dict[str, Any]]: if len(raw_waypoints) > 64: raise ValueError("waypoints must contain at most 64 items") normalized: list[dict[str, Any]] = [] seen_ids: set[str] = set() for index, item in enumerate(raw_waypoints, start=1): if not isinstance(item, dict): raise ValueError(f"waypoints[{index - 1}] must be an object") waypoint_id = str(item.get("id") or f"wp_{index:02d}").strip() if not waypoint_id: waypoint_id = f"wp_{index:02d}" if waypoint_id in seen_ids: waypoint_id = f"{waypoint_id}_{index:02d}" seen_ids.add(waypoint_id) display_name = str(item.get("display_name") or f"巡检点 {index}").strip() or f"巡检点 {index}" try: x = float(item.get("x")) y = float(item.get("y")) yaw_deg = float(item.get("yaw_deg")) except (TypeError, ValueError) as exc: raise ValueError(f"waypoints[{index - 1}] pose values must be numeric") from exc if not (math.isfinite(x) and math.isfinite(y) and math.isfinite(yaw_deg)): raise ValueError(f"waypoints[{index - 1}] contains non-finite pose values") frame_id = str(item.get("frame_id") or "map").strip() if frame_id != "map": raise ValueError("waypoint frame_id must be map") normalized.append( { "id": waypoint_id, "display_name": display_name, "x": x, "y": y, "yaw_deg": normalize_yaw_deg(yaw_deg), "frame_id": "map", "enabled": bool(item.get("enabled", True)), } ) return normalized def _write_inspection_route(self, route: dict[str, Any]) -> None: self.route_path.parent.mkdir(parents=True, exist_ok=True) temp_path = self.route_path.with_suffix(".json.tmp") temp_path.write_text(json.dumps(route, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temp_path.replace(self.route_path) def load_patrol_route(self) -> dict[str, Any]: if not self.patrol_route_path.exists(): return {"route_name": "default", "frame_id": "map", "created_by": "manual", "waypoints": []} raw = yaml.safe_load(self.patrol_route_path.read_text(encoding="utf-8")) or {} if not isinstance(raw, dict): raw = {} waypoints = raw.get("waypoints") or [] if not isinstance(waypoints, list): waypoints = [] return { "route_name": raw.get("route_name", "default"), "frame_id": raw.get("frame_id", "map"), "created_by": raw.get("created_by", "manual"), "waypoints": waypoints, } def save_patrol_route(self, data: dict[str, Any]) -> dict[str, Any]: waypoints = data.get("waypoints") if not isinstance(waypoints, list): raise ValueError("waypoints must be a list") if len(waypoints) > 64: raise ValueError("waypoints must contain at most 64 items") validated = [] for i, wp in enumerate(waypoints): if not isinstance(wp, dict): raise ValueError(f"waypoints[{i}] must be an object") name = str(wp.get("name", f"waypoint_{i+1}")).strip() or f"waypoint_{i+1}" try: x = float(wp["x"]) y = float(wp["y"]) yaw = float(wp["yaw"]) except (KeyError, TypeError, ValueError) as exc: raise ValueError(f"waypoints[{i}] x/y/yaw must be numeric") from exc enabled = bool(wp.get("enabled", True)) validated.append({ "name": name, "frame_id": "map", "x": round(x, 3), "y": round(y, 3), "yaw": round(yaw, 3), "enabled": enabled, }) route = { "route_name": str(data.get("route_name", "default")).strip() or "default", "frame_id": "map", "created_by": "dashboard", "waypoints": validated, } self.patrol_route_path.parent.mkdir(parents=True, exist_ok=True) tmp = self.patrol_route_path.with_suffix(".yaml.tmp") tmp.write_text(self._format_patrol_yaml(route), encoding="utf-8") tmp.replace(self.patrol_route_path) return route def _format_patrol_yaml(self, route: dict[str, Any]) -> str: lines = [] lines.append(f"route_name: {route['route_name']}") lines.append(f"frame_id: {route['frame_id']}") lines.append(f"created_by: {route['created_by']}") lines.append("waypoints:") for wp in route.get("waypoints", []): lines.append(f" - name: {wp['name']}") lines.append(f" frame_id: {wp['frame_id']}") lines.append(f" x: {wp['x']}") lines.append(f" y: {wp['y']}") lines.append(f" yaw: {wp['yaw']}") lines.append(f" enabled: {'true' if wp.get('enabled', True) else 'false'}") return "\n".join(lines) + "\n" def map_payload(self) -> dict[str, Any]: with self._lock: if self._map: return deepcopy(self._map) fallback = self._load_fallback_map() if fallback: return fallback raise RuntimeError("No /map data received and map file fallback is unavailable") def publish_initial_pose(self, pose_data: dict[str, Any]) -> dict[str, Any]: x = require_float(pose_data, "x") y = require_float(pose_data, "y") yaw_deg = require_float(pose_data, "yaw_deg") self._invalidate_localization_confirmation("initial_pose_published") msg = PoseWithCovarianceStamped() msg.header.frame_id = "map" msg.header.stamp = Time().to_msg() msg.pose.pose.position.x = x msg.pose.pose.position.y = y qx, qy, qz, qw = yaw_to_quaternion(math.radians(yaw_deg)) msg.pose.pose.orientation.x = qx msg.pose.pose.orientation.y = qy msg.pose.pose.orientation.z = qz msg.pose.pose.orientation.w = qw msg.pose.covariance[0] = 0.25 msg.pose.covariance[7] = 0.25 msg.pose.covariance[35] = 0.0685 for _ in range(3): self.initial_pose_pub.publish(msg) time.sleep(0.04) return {"name": "initialPose/published", "pose": {"x": x, "y": y, "yaw_deg": yaw_deg, "frame_id": "map"}} def confirm_localization(self) -> dict[str, Any]: map_payload = self.map_payload() with self._lock: pose = deepcopy(self._pose) nav_launch_pid = self._nav_launch_pid() nav2_action_ready = bool(self.nav_client.server_is_ready()) preflight = self._preflight_state(pose, map_payload, nav_launch_pid, nav2_action_ready) robot_ros = preflight["robot_ros"] localization = preflight["localization"] if not nav_launch_pid: raise RuntimeError("Nav2 launch is not running") if not nav2_action_ready: raise RuntimeError("Nav2 /navigate_to_pose action server is not ready") if not robot_ros["ready"]: reasons = [] if robot_ros["stale_sources"]: reasons.append("missing fresh source data: " + ", ".join(robot_ros["stale_sources"])) if robot_ros["missing_command_subscriber"]: reasons.append(f"missing command subscriber: {ROBOT_COMMAND_TOPIC}") raise RuntimeError("Robot ROS link is not ready: " + "; ".join(reasons)) if not localization["pose_fresh"] or not pose: raise RuntimeError("Current map pose is unavailable or stale; set initial pose first") confirmation = { "state": "confirmed", "confirmed_at_unix_s": now_s(), "map_yaml": str(self.map_yaml), "map_revision": int(map_payload["revision"]), "nav_launch_pid": nav_launch_pid, "pose": { "x": float(pose["x"]), "y": float(pose["y"]), "yaw_deg": float(pose["yaw_deg"]), "frame_id": "map", "source": pose.get("raw_source") or pose.get("source"), }, } with self._lock: self._localization_confirmation = deepcopy(confirmation) return {"name": "localizationChecks/current", **confirmation} def _clear_inactive_current_goal_locked(self) -> bool: if self._current_goal_handle is None: return False goal_state = str(self._goal.get("state") or "") inspection_state = str(self._inspection_run.get("state") or "") if goal_state in GOAL_INACTIVE_STATES and inspection_state not in INSPECTION_ACTIVE_STATES: self._current_goal_handle = None self._current_goal_sequence = None return True return False def navigate_to_pose(self, pose_data: dict[str, Any]) -> dict[str, Any]: x = require_float(pose_data, "x") y = require_float(pose_data, "y") yaw_deg = require_float(pose_data, "yaw_deg") with self._lock: self._clear_inactive_current_goal_locked() if self._inspection_run.get("state") in INSPECTION_ACTIVE_STATES: raise RuntimeError("Inspection route is running; stop it before sending a manual goal") if self._current_goal_handle is not None: raise RuntimeError("A navigation goal is already active") with self._lock: pose = deepcopy(self._pose) if not pose or now_s() - float(pose.get("received_at_unix_s", 0.0)) > 5.0: raise RuntimeError("当前地图位姿不可用或已过期,请先重新设置初始位姿") network_blocking_error = os.environ.get("OPEN_SOURCE_DASHBOARD_NETWORK_BLOCKING_ERROR") if network_blocking_error: raise RuntimeError(f"\u673a\u5668\u4eba\u7f51\u7edc\u94fe\u8def\u5f02\u5e38\uff0c\u7981\u6b62\u4e0b\u53d1\u76ee\u6807\u70b9\uff1a{network_blocking_error}") map_payload = self.load_map_payload() nav_launch_pid = self._nav_launch_pid() nav2_action_ready = bool(self.nav_client.server_is_ready()) preflight = self._preflight_state(pose, map_payload, nav_launch_pid, nav2_action_ready) if not preflight["localization"]["confirmed"]: reason = preflight["localization"].get("reason") or "定位未确认" raise RuntimeError(f"定位未确认,禁止下发目标点:{reason}。请重新设置初始位姿并点击确认定位。") if not self._is_free_goal(x, y): raise ValueError("Goal must be inside known free space on the current map") if not self.nav_client.wait_for_server(timeout_sec=1.0): raise RuntimeError("Nav2 /navigate_to_pose action server is not ready") return self._send_navigation_goal(x, y, yaw_deg, context=None) def _send_navigation_goal(self, x: float, y: float, yaw_deg: float, context: dict[str, Any] | None, behavior_tree: str | None = None) -> dict[str, Any]: goal_msg = NavigateToPose.Goal() goal_msg.pose = PoseStamped() goal_msg.pose.header.frame_id = "map" # Nav2 can re-transform a goal during replanning. A zero stamp asks TF # for the latest transform and avoids stale-goal extrapolation on # multi-waypoint runs. goal_msg.pose.header.stamp = Time().to_msg() goal_msg.pose.pose.position.x = x goal_msg.pose.pose.position.y = y qx, qy, qz, qw = yaw_to_quaternion(math.radians(yaw_deg)) goal_msg.pose.pose.orientation.x = qx goal_msg.pose.pose.orientation.y = qy goal_msg.pose.pose.orientation.z = qz goal_msg.pose.pose.orientation.w = qw if behavior_tree: goal_msg.behavior_tree = behavior_tree with self._lock: self._goal_sequence += 1 sequence = self._goal_sequence target = {"x": x, "y": y, "yaw_deg": yaw_deg, "frame_id": "map"} if behavior_tree: target["behavior_tree"] = behavior_tree if context: target.update(deepcopy(context)) self._goal = { "state": "sending", "sequence": sequence, "target": target, "updated_at_unix_s": now_s(), } future = self.nav_client.send_goal_async(goal_msg) future.add_done_callback(lambda done, goal_sequence=sequence: self._goal_response_callback(done, goal_sequence)) return { "name": "navigateToPose/sending", "sequence": sequence, "target": target, } def _is_free_goal(self, x: float, y: float) -> bool: grid = self.map_payload() origin = grid["origin"] resolution = float(grid["resolution"]) width = int(grid["width"]) height = int(grid["height"]) mx = int(math.floor((x - float(origin["x"])) / resolution)) my = int(math.floor((y - float(origin["y"])) / resolution)) if mx < 0 or my < 0 or mx >= width or my >= height: return False cells = grid.get("cells") or [] value = int(cells[my * width + mx]) return 0 <= value < 50 def stop_motion(self) -> dict[str, Any]: with self._lock: active_run_id = self._inspection_run.get("run_id") if self._inspection_run.get("state") in INSPECTION_ACTIVE_STATES else None if active_run_id is not None: self._inspection_run["cancel_requested"] = True self._inspection_run["state"] = "canceling" self._inspection_run["error"] = "\u5df2\u624b\u52a8\u505c\u6b62\u5de1\u68c0" self._inspection_run["updated_at_unix_s"] = now_s() goal_handle = self._current_goal_handle previous_sequence = self._goal_sequence self._goal_sequence += 1 self._goal = {"state": "stopping", "sequence": self._goal_sequence, "updated_at_unix_s": now_s(), "previous": deepcopy(self._goal)} if goal_handle is not None: try: cancel_future = goal_handle.cancel_goal_async() cancel_future.add_done_callback(lambda done, goal_sequence=previous_sequence: self._cancel_callback(done, goal_sequence)) except Exception as exc: self.get_logger().warning(f"Failed to cancel current goal: {exc}") zero = Twist() for _ in range(12): self.cmd_vel_pub.publish(zero) self.cmd_vel_nav_pub.publish(zero) time.sleep(0.04) if active_run_id is not None: self._finish_inspection_run(active_run_id, "canceled", error="\u5df2\u624b\u52a8\u505c\u6b62\u5de1\u68c0") return {"name": "robotCommands/stopMove", "state": "stopping"} def start_navigation(self) -> dict[str, Any]: # Basic communication check robot_ros = self._robot_ros_link_state() if not robot_ros["sources_fresh"]: stale = ", ".join(robot_ros["stale_sources"]) raise RuntimeError(f"机器人 ROS 通信未就绪,缺少数据源: {stale}。请检查机器狗是否开机、网络连接是否正常。") if not robot_ros["command_ready"]: raise RuntimeError(f"机器人指令链路未就绪,缺少订阅者: {ROBOT_COMMAND_TOPIC}。请确保机器狗驱动正在运行。") existing_pid = self._nav_launch_pid() if existing_pid is not None: with self._lock: pose = deepcopy(self._pose) map_payload = self.map_payload() preflight = self._preflight_state(pose, map_payload, existing_pid, bool(self.nav_client.server_is_ready())) return {"name": "navigation/alreadyRunning", "pid": existing_pid, "preflight": preflight} self.nav_log_dir.mkdir(parents=True, exist_ok=True) log_path = self.nav_log_dir / f"nav_{time.strftime('%Y%m%d_%H%M%S')}_dashboard.log" env = os.environ.copy() env["GO2_OS_USE_RVIZ"] = "true" if "DISPLAY" not in env: env["DISPLAY"] = ":0" if "WAYLAND_DISPLAY" not in env: env["WAYLAND_DISPLAY"] = "wayland-0" env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" map_name = self.map_yaml.stem cmd = [str(self.repo_root / "scripts" / "go2_start_nav.sh"), map_name] self._invalidate_localization_confirmation("navigation_started") log_file = log_path.open("ab") proc = subprocess.Popen( cmd, cwd=self.repo_root, env=env, stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True, ) (self.nav_log_dir / "nav.pid").write_text(str(proc.pid), encoding="utf-8") time.sleep(1.0) if proc.poll() is not None: log_tail = "" try: log_tail = "\n".join(log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-20:]) except Exception: pass raise RuntimeError(f"Nav2 launch exited early with code {proc.returncode}. {log_tail}") with self._lock: pose = deepcopy(self._pose) map_payload = self.map_payload() preflight = self._preflight_state(pose, map_payload, proc.pid, bool(self.nav_client.server_is_ready())) return {"name": "navigation/started", "pid": proc.pid, "log_path": str(log_path), "preflight": preflight} def stop_navigation(self) -> dict[str, Any]: pid = self._nav_launch_pid() if pid is None: return {"name": "navigation/notRunning"} try: os.killpg(os.getpgid(pid), signal.SIGTERM) except (ProcessLookupError, PermissionError): try: os.kill(pid, signal.SIGTERM) except (ProcessLookupError, PermissionError): pass time.sleep(1.0) still_running = self._nav_launch_pid() if still_running: try: os.killpg(os.getpgid(still_running), signal.SIGKILL) except (ProcessLookupError, PermissionError): try: os.kill(still_running, signal.SIGKILL) except (ProcessLookupError, PermissionError): pass pid_file = self.nav_log_dir / "nav.pid" if pid_file.exists(): pid_file.unlink() return {"name": "navigation/stopped", "pid": pid} def _assert_obstacle_avoidance_config(self) -> None: params_path = self.repo_root / "src" / "go2_navigation2" / "config" / "nav2_params.yaml" if not params_path.exists(): raise RuntimeError(f"Nav2 obstacle avoidance config is missing: {params_path}") try: payload = yaml.safe_load(params_path.read_text(encoding="utf-8")) or {} local_params = payload["local_costmap"]["local_costmap"]["ros__parameters"] global_params = payload["global_costmap"]["global_costmap"]["ros__parameters"] local_plugins = set(local_params.get("plugins") or []) global_plugins = set(global_params.get("plugins") or []) local_obstacle_enabled = ( ("voxel_layer" in local_plugins and local_params.get("voxel_layer", {}).get("enabled", True) is not False) or ("obstacle_layer" in local_plugins and local_params.get("obstacle_layer", {}).get("enabled", True) is not False) ) global_obstacle_enabled = "obstacle_layer" in global_plugins and global_params.get("obstacle_layer", {}).get("enabled", True) is not False local_inflation_enabled = "inflation_layer" in local_plugins and float(local_params.get("inflation_layer", {}).get("inflation_radius", 0.0)) > 0.0 global_inflation_enabled = "inflation_layer" in global_plugins and float(global_params.get("inflation_layer", {}).get("inflation_radius", 0.0)) > 0.0 except Exception as exc: raise RuntimeError(f"无法校验 Nav2 避障配置:{exc}") from exc if not (local_obstacle_enabled and global_obstacle_enabled and local_inflation_enabled and global_inflation_enabled): raise RuntimeError("Nav2 避障配置未启用,无法开始导航") def start_inspection_route(self) -> dict[str, Any]: patrol_data = self.load_patrol_route() waypoints = [wp for wp in patrol_data.get("waypoints", []) if wp.get("enabled", True)] if not waypoints: raise ValueError("巡检路线没有可用巡检点(请在航点编辑器中添加航点)") with self._lock: self._clear_inactive_current_goal_locked() pose = deepcopy(self._pose) if self._inspection_run.get("state") in INSPECTION_ACTIVE_STATES: raise RuntimeError("巡检路线正在运行中") if self._current_goal_handle is not None: raise RuntimeError("当前已经有一个导航目标在执行") if not self.nav_client.wait_for_server(timeout_sec=1.0): raise RuntimeError("Nav2 /navigate_to_pose action server 未就绪") route = { "name": "inspectionRoutes/patrol", "display_name": patrol_data.get("route_name", "default"), "waypoints": [ { "id": f"wp_{i+1}", "display_name": wp.get("name", f"waypoint_{i+1}"), "x": float(wp["x"]), "y": float(wp["y"]), "yaw": float(wp["yaw"]), "yaw_deg": math.degrees(float(wp["yaw"])), "enabled": True, } for i, wp in enumerate(waypoints) ], } waypoints_for_nav = route["waypoints"] with self._lock: self._inspection_run_sequence += 1 run_id = self._inspection_run_sequence self._inspection_run = { "name": "inspectionRuns/current", "run_id": run_id, "state": "starting", "status_text": "巡检启动中", "route_name": route["name"], "route_display_name": route["display_name"], "current_index": 0, "completed_count": 0, "total_count": len(waypoints_for_nav), "current_waypoint": None, "arrival_check": None, "realtime_segment": None, "cancel_requested": False, "started_at_unix_s": now_s(), "updated_at_unix_s": now_s(), "dwell_started_at_unix_s": None, "dwell_duration_s": INSPECTION_DWELL_DURATION_S, "dwell_elapsed_s": 0.0, "dwell_remaining_s": INSPECTION_DWELL_DURATION_S, "dwell_progress_ratio": 0.0, } thread = threading.Thread(target=self._inspection_route_worker, args=(run_id, route, waypoints_for_nav), daemon=True) thread.start() return { "name": "inspectionRuns/current", "run_id": run_id, "state": "starting", "status_text": "巡检启动中", "total_count": len(waypoints_for_nav), } def cancel_inspection_run(self) -> dict[str, Any]: response = self.stop_motion() return {"name": "inspectionRuns/current", "state": "canceled", "stop": response} def _inspection_route_worker(self, run_id: int, route: dict[str, Any], waypoints: list[dict[str, Any]]) -> None: goal_timeout_s = float(os.environ.get("GO2_INSPECTION_GOAL_TIMEOUT_S", "600")) arrival_timeout_s = float(os.environ.get("GO2_INSPECTION_ARRIVAL_TIMEOUT_S", "20")) localization_ready_for_next = False try: for index, waypoint in enumerate(waypoints): if self._inspection_run_canceled(run_id): return waypoint_label = self._inspection_waypoint_label(index) context = { "inspection_run_id": run_id, "waypoint_id": waypoint.get("id"), "waypoint_display_name": waypoint.get("display_name"), "waypoint_index": index, } if localization_ready_for_next: localization_ready_for_next = False else: refresh_ok, refresh_result = self._refresh_localization_for_inspection(run_id, "before_navigation", waypoint_label) if not refresh_ok: self._finish_inspection_run( run_id, "failed", error=f"{waypoint_label} localization refresh failed before navigation", error_detail=self._localization_refresh_error_detail(refresh_result), ) return self._patch_inspection_run( run_id, { "state": "navigating", "status_text": f"前往第 {index + 1} 个点", "current_index": index, "current_waypoint": deepcopy(waypoint), "goal_sequence": None, "arrival_check": None, "realtime_segment": None, "dwell_started_at_unix_s": None, "dwell_elapsed_s": 0.0, "dwell_remaining_s": INSPECTION_DWELL_DURATION_S, "dwell_progress_ratio": 0.0, "updated_at_unix_s": now_s(), }, ) nav_yaw_deg = float(waypoint["yaw_deg"]) result = self._send_navigation_goal(float(waypoint["x"]), float(waypoint["y"]), nav_yaw_deg, context=context, behavior_tree=None) sequence = int(result["sequence"]) self._patch_inspection_run(run_id, {"goal_sequence": sequence, "updated_at_unix_s": now_s()}) goal_state = self._wait_for_goal_terminal(run_id, sequence, goal_timeout_s) if goal_state != "succeeded": failure_detail = None if goal_state in {"aborted", "failed", "timeout"}: failure_detail = self._diagnose_navigation_failure(goal_state) with self._lock: pose_for_fallback = deepcopy(self._pose) distance_for_fallback = None if pose_for_fallback: distance_for_fallback = math.hypot(float(pose_for_fallback["x"]) - float(waypoint["x"]), float(pose_for_fallback["y"]) - float(waypoint["y"])) if goal_state in {"aborted", "failed", "timeout"} and distance_for_fallback is not None and distance_for_fallback <= INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M: self._publish_zero_velocity_burst() near_goal_check = { "ready": False, "fresh": True, "in_position": distance_for_fallback <= INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M, "in_yaw": False, "stopped": True, "distance_m": distance_for_fallback, "yaw_error_deg": None, "pose_age_s": None, "linear_speed_mps": 0.0, "yaw_rate_deg_s": 0.0, "stable_for_s": 0.0, "required_stable_s": INSPECTION_ARRIVAL_STABLE_HOLD_S, "position_tolerance_m": INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M, "yaw_tolerance_deg": INSPECTION_YAW_TOLERANCE_DEG, "near_goal_after_nav_abort": True, "reason": f"Nav2 {goal_state},但已接近巡检点 {distance_for_fallback:.3f}m,继续原地对准朝向", "failure_category": failure_detail.get("category") if failure_detail else None, "error_detail": failure_detail.get("message") if failure_detail else None, "checked_at_unix_s": now_s(), } self._patch_inspection_run( run_id, { "arrival_check": near_goal_check, "nav_failure_detail": failure_detail, "status_text": f"{waypoint_label},Nav2近点中止,继续对准朝向", "updated_at_unix_s": now_s(), }, ) else: self._publish_zero_velocity_burst() if goal_state == "canceled": self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") elif goal_state == "timeout": self._finish_inspection_run(run_id, "failed", error=f"{waypoint_label}导航超时", error_detail=failure_detail) else: self._finish_inspection_run(run_id, "failed", error=f"{waypoint_label}导航失败({goal_state})", error_detail=failure_detail) return arrival_message = self._inspection_waypoint_label(index) if goal_state != "succeeded": refresh_ok, refresh_result = self._refresh_localization_for_inspection(run_id, "after_navigation", waypoint_label) if not refresh_ok: self._finish_inspection_run( run_id, "failed", error=f"{waypoint_label} localization refresh failed after navigation", error_detail=self._localization_refresh_error_detail(refresh_result), ) return self._patch_inspection_run( run_id, { "state": "arrived", "status_text": f"{arrival_message},正在对准朝向", "updated_at_unix_s": now_s(), }, ) arrival_position_tolerance_m = INSPECTION_POSITION_TOLERANCE_M if goal_state == "succeeded": # Nav2 already accepted the position-only goal. Use a slightly wider # confirmation band here so final yaw alignment is not blocked by # small AMCL/map-frame settling differences after controller success. arrival_position_tolerance_m = max( arrival_position_tolerance_m, INSPECTION_NAV_SUCCESS_NEAR_GOAL_TOLERANCE_M, ) if ( goal_state in {"aborted", "failed", "timeout"} and distance_for_fallback is not None and distance_for_fallback <= INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M ): arrival_position_tolerance_m = INSPECTION_NAV_ABORT_NEAR_GOAL_TOLERANCE_M align_check, align_reason = self._align_waypoint_yaw(run_id, sequence, index, waypoint, arrival_position_tolerance_m) if align_reason is not None: self._publish_zero_velocity_burst() if align_reason == "canceled": self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") else: detail = f":{align_check.get('reason')}" if align_check and align_check.get("reason") else "" self._finish_inspection_run(run_id, "failed", error=f"{waypoint_label}朝向对准失败{detail}") return arrival_check = align_check self._patch_inspection_run( run_id, { "state": "arrived", "status_text": arrival_message, "arrival_check": deepcopy(arrival_check), "updated_at_unix_s": now_s(), }, ) # --- Position correction: use stationary laser to fix AMCL drift --- correction_result = self._correct_position_at_waypoint(run_id, index, waypoint, waypoint_label) if correction_result.get("canceled"): self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") return if correction_result.get("failed"): self._finish_inspection_run(run_id, "failed", error=correction_result["error"]) return # Re-align yaw after position correction (correction may drift heading) re_align_check, re_align_reason = self._align_waypoint_yaw(run_id, sequence, index, waypoint, INSPECTION_POSITION_TOLERANCE_M) if re_align_reason is not None: self._publish_zero_velocity_burst() if re_align_reason == "canceled": self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") else: self._finish_inspection_run(run_id, "failed", error=f"{waypoint_label}修正后朝向对准失败") return segment_result = self._call_realtime_segment_for_waypoint(run_id, index, waypoint) self._patch_inspection_run( run_id, { "realtime_segment": deepcopy(segment_result), "updated_at_unix_s": now_s(), }, ) if self._inspection_run_canceled(run_id): self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") return if not segment_result.get("continue_inspection", True): self._publish_zero_velocity_burst() self._finish_inspection_run( run_id, "failed", error=f"{waypoint_label}实时识别接口调用失败:{segment_result.get('message', '未知错误')}。已停止巡检。", ) return self._patch_inspection_run( run_id, { "state": "dwelling", "status_text": arrival_message, "arrival_check": deepcopy(arrival_check), "dwell_started_at_unix_s": now_s(), "dwell_duration_s": INSPECTION_DWELL_DURATION_S, "dwell_elapsed_s": 0.0, "dwell_remaining_s": INSPECTION_DWELL_DURATION_S, "dwell_progress_ratio": 0.0, "updated_at_unix_s": now_s(), }, ) dwell_ok, dwell_reason, dwell_check = self._run_inspection_dwell(run_id, sequence, index, waypoint, arrival_message) if not dwell_ok: self._publish_zero_velocity_burst() if dwell_reason == "canceled": self._finish_inspection_run(run_id, "canceled", error="已手动停止巡检") else: detail = f":{dwell_reason}" if dwell_reason else "" self._finish_inspection_run(run_id, "failed", error=f"{waypoint_label}停留失败{detail}") return next_count = index + 1 if next_count < len(waypoints): next_waypoint = deepcopy(waypoints[next_count]) refresh_ok, refresh_result = self._refresh_localization_for_inspection(run_id, "before_next_waypoint", waypoint_label) if not refresh_ok: self._finish_inspection_run( run_id, "failed", error=f"{waypoint_label} localization refresh failed before next waypoint", error_detail=self._localization_refresh_error_detail(refresh_result), ) return localization_ready_for_next = True self._patch_inspection_run( run_id, { "state": "navigating", "status_text": f"前往第 {next_count + 1} 个点", "current_index": next_count, "current_waypoint": next_waypoint, "completed_count": next_count, "goal_sequence": None, "arrival_check": None, "realtime_segment": None, "dwell_started_at_unix_s": None, "dwell_elapsed_s": 0.0, "dwell_remaining_s": INSPECTION_DWELL_DURATION_S, "dwell_progress_ratio": 0.0, "updated_at_unix_s": now_s(), }, ) continue self._patch_inspection_run( run_id, { "state": "completed", "status_text": "巡检完成", "completed_count": next_count, "arrival_check": deepcopy(dwell_check or arrival_check), "dwell_elapsed_s": INSPECTION_DWELL_DURATION_S, "dwell_remaining_s": 0.0, "dwell_progress_ratio": 1.0, "finished_at_unix_s": now_s(), "updated_at_unix_s": now_s(), }, ) return self._finish_inspection_run(run_id, "completed", error=None) except Exception as exc: self._publish_zero_velocity_burst() self._finish_inspection_run(run_id, "failed", error=f"巡检异常:{exc}") def _correct_position_at_waypoint(self, run_id: int, index: int, waypoint, waypoint_label: str) -> dict[str, Any]: """Correct position using direct cmd_vel XY translation (sport mode).""" import time as _time result: dict[str, Any] = {"corrected": False, "canceled": False, "failed": False} self._patch_inspection_run( run_id, { "state": "correcting", "status_text": f"{waypoint_label},位置修正中", "updated_at_unix_s": now_s(), }, ) # Stop and wait for gait to settle self._publish_zero_velocity_burst() _time.sleep(1.5) # Force AMCL to process stationary laser scan nomotion_result = self._request_amcl_nomotion_update() _time.sleep(1.0) # Check position error after AMCL update with self._lock: pose = deepcopy(self._pose) if not pose or pose.get("x") is None: result["error"] = f"{waypoint_label}位置修正失败:无法获取当前位姿" result["failed"] = True return result wp_x = float(waypoint.get("x", 0)) wp_y = float(waypoint.get("y", 0)) current_x = float(pose["x"]) current_y = float(pose["y"]) position_error = math.hypot(wp_x - current_x, wp_y - current_y) self._patch_inspection_run( run_id, { "state": "correcting", "status_text": f"{waypoint_label},位置误差 {position_error:.3f}m,修正中", "position_correction": { "initial_error_m": position_error, "threshold_m": INSPECTION_POSITION_CORRECTION_THRESHOLD_M, "nomotion_update": nomotion_result, "updated_at_unix_s": now_s(), }, "updated_at_unix_s": now_s(), }, ) # If error is within threshold, no correction needed if position_error <= INSPECTION_POSITION_CORRECTION_THRESHOLD_M: self._patch_inspection_run( run_id, { "state": "correcting", "status_text": f"{waypoint_label},位置修正完成(误差 {position_error:.3f}m)", "position_correction": { "initial_error_m": position_error, "final_error_m": position_error, "correction_needed": False, "threshold_m": INSPECTION_POSITION_CORRECTION_THRESHOLD_M, "updated_at_unix_s": now_s(), }, "updated_at_unix_s": now_s(), }, ) result["corrected"] = True result["initial_error_m"] = position_error result["final_error_m"] = position_error return result # Position error exceeds threshold - use direct cmd_vel correction self.get_logger().info( f"Position correction at {waypoint_label}: error={position_error:.3f}m > threshold={INSPECTION_POSITION_CORRECTION_THRESHOLD_M}m, using cmd_vel" ) if self._inspection_run_canceled(run_id): result["canceled"] = True return result # Direct cmd_vel proportional control loop CORRECTION_MAX_SPEED = 0.25 CORRECTION_MIN_SPEED = 0.20 CORRECTION_TIMEOUT_S = 30.0 CORRECTION_SETTLE_S = 0.8 CORRECTION_LOOP_HZ = 30.0 deadline = now_s() + CORRECTION_TIMEOUT_S converged = False while now_s() < deadline: if self._inspection_run_canceled(run_id): self._publish_zero_velocity_burst() result["canceled"] = True return result with self._lock: pose = deepcopy(self._pose) if not pose or pose.get("x") is None: _time.sleep(1.0 / CORRECTION_LOOP_HZ) continue current_x = float(pose["x"]) current_y = float(pose["y"]) current_yaw_rad = math.radians(float(pose["yaw_deg"])) err_x_map = wp_x - current_x err_y_map = wp_y - current_y dist = math.hypot(err_x_map, err_y_map) if dist <= INSPECTION_POSITION_CORRECTION_THRESHOLD_M: converged = True break # Transform map-frame error to base_link frame cos_yaw = math.cos(-current_yaw_rad) sin_yaw = math.sin(-current_yaw_rad) err_x_base = cos_yaw * err_x_map - sin_yaw * err_y_map err_y_base = sin_yaw * err_x_map + cos_yaw * err_y_map # Proportional speed with min/max clamp speed = min(CORRECTION_MAX_SPEED, max(CORRECTION_MIN_SPEED, dist * 0.5)) vx = (err_x_base / dist) * speed vy = (err_y_base / dist) * speed twist = Twist() twist.linear.x = vx twist.linear.y = vy twist.angular.z = 0.0 self.cmd_vel_pub.publish(twist) _time.sleep(1.0 / CORRECTION_LOOP_HZ) # Stop movement self._publish_zero_velocity_burst() _time.sleep(CORRECTION_SETTLE_S) # Refresh localization after correction self._request_amcl_nomotion_update() _time.sleep(0.8) # Check final position error with self._lock: pose = deepcopy(self._pose) final_error = position_error if pose and pose.get("x") is not None: final_error = math.hypot(wp_x - float(pose["x"]), wp_y - float(pose["y"])) converge_text = "ok" if converged else "timeout" self._patch_inspection_run( run_id, { "state": "correcting", "status_text": f"{waypoint_label},位置修正完成({position_error:.3f}m -> {final_error:.3f}m {converge_text})", "position_correction": { "initial_error_m": position_error, "final_error_m": final_error, "correction_needed": True, "converged": converged, "method": "cmd_vel_direct", "threshold_m": INSPECTION_POSITION_CORRECTION_THRESHOLD_M, "updated_at_unix_s": now_s(), }, "updated_at_unix_s": now_s(), }, ) self.get_logger().info( f"Position correction complete at {waypoint_label}: {position_error:.3f}m -> {final_error:.3f}m ({converge_text})" ) result["corrected"] = True result["initial_error_m"] = position_error result["final_error_m"] = final_error return result def _call_realtime_segment_for_waypoint(self, run_id: int, index: int, waypoint: dict[str, Any]) -> dict[str, Any]: url = os.environ.get("GO2_REALTIME_SEGMENT_URL", REALTIME_SEGMENT_DEFAULT_URL).strip() or REALTIME_SEGMENT_DEFAULT_URL image_field = os.environ.get("GO2_REALTIME_SEGMENT_IMAGE_FIELD", REALTIME_SEGMENT_DEFAULT_IMAGE_FIELD).strip() or REALTIME_SEGMENT_DEFAULT_IMAGE_FIELD timeout_s = max(1.0, safe_float_env("GO2_REALTIME_SEGMENT_TIMEOUT_S", 8.0)) frame_timeout_s = max(0.1, safe_float_env("GO2_REALTIME_SEGMENT_FRAME_TIMEOUT_S", 2.0)) continue_on_failure = env_bool("GO2_REALTIME_SEGMENT_CONTINUE_ON_FAILURE", False) waypoint_label = self._inspection_waypoint_label(index) started_at = now_s() base_result = { "name": f"inspectionRuns/{run_id}/realtimeSegmentCalls/{index + 1}", "state": "running", "ok": False, "method": "POST", "url": url, "image_field": image_field, "waypoint_index": index, "waypoint_id": waypoint.get("id"), "waypoint_display_name": waypoint.get("display_name"), "continue_on_failure": continue_on_failure, "decision": "调用中,尚未决定是否继续巡检", "started_at_unix_s": started_at, "updated_at_unix_s": started_at, } self._patch_inspection_run( run_id, { "state": "segmenting", "status_text": f"{waypoint_label},调用实时识别接口", "realtime_segment": deepcopy(base_result), "updated_at_unix_s": now_s(), }, ) def finish_failure(message: str, **extra: Any) -> dict[str, Any]: finished_at = now_s() decision = "接口调用失败,已继续巡检" if continue_on_failure else "接口调用失败,已停止巡检" return { **base_result, **extra, "state": "failed", "ok": False, "message": message, "continue_inspection": continue_on_failure, "decision": decision, "finished_at_unix_s": finished_at, "updated_at_unix_s": finished_at, "duration_s": max(0.0, finished_at - started_at), } sampler = self.camera_sampler if sampler is None: return finish_failure("摄像头采样器未初始化,无法上传图片") frame_item = sampler.wait_for_fresh_frame(frame_timeout_s, REALTIME_SEGMENT_FRAME_MAX_AGE_S) if frame_item is None: camera_status = sampler.status() if camera_status.get("error"): reason = f"没有取得可上传图片:{camera_status['error']}" elif camera_status.get("frame_age_s") is not None: reason = f"没有取得新鲜图片,最近一帧已过期 {float(camera_status['frame_age_s']):.1f} 秒" else: reason = "没有取得可上传图片,摄像头暂未返回 JPEG 帧" return finish_failure(reason, camera=camera_status) frame_id, frame, frame_updated_at = frame_item fields = { "inspection_run_id": str(run_id), "waypoint_index": str(index), "waypoint_display_name": str(waypoint.get("display_name") or waypoint_label), "waypoint_id": str(waypoint.get("id") or ""), } body, content_type = encode_multipart_form_data(fields, image_field, "go2-inspection-frame.jpg", "image/jpeg", frame) request = Request( url, data=body, method="POST", headers={ "Accept": "application/json", "Content-Type": content_type, "Content-Length": str(len(body)), }, ) try: with urlopen(request, timeout=timeout_s) as response: status = int(response.status) response_text = response.read(1024 * 1024).decode("utf-8", "replace") except HTTPError as exc: response_text = exc.read(1024 * 1024).decode("utf-8", "replace") response_payload = parse_json_response(response_text) message = extract_response_error(response_payload, f"HTTP {exc.code}") return finish_failure( f"实时识别接口返回 HTTP {exc.code}:{message}", http_status=int(exc.code), response_json=response_payload, response_text=truncate_text(response_text), frame_id=frame_id, frame_age_s=max(0.0, now_s() - frame_updated_at), ) except (TimeoutError, URLError, OSError) as exc: return finish_failure( f"实时识别接口请求失败:{exc}", frame_id=frame_id, frame_age_s=max(0.0, now_s() - frame_updated_at), ) response_payload = parse_json_response(response_text) finished_at = now_s() return { **base_result, "state": "succeeded", "ok": True, "message": "实时识别接口调用成功", "continue_inspection": True, "decision": "接口调用成功,继续巡检", "http_status": status, "response_json": response_payload, "response_text": truncate_text(response_text), "frame_id": frame_id, "frame_age_s": max(0.0, finished_at - frame_updated_at), "finished_at_unix_s": finished_at, "updated_at_unix_s": finished_at, "duration_s": max(0.0, finished_at - started_at), } def _wait_for_goal_terminal(self, run_id: int, sequence: int, timeout_s: float) -> str: deadline = now_s() + max(1.0, timeout_s) while now_s() < deadline: if self._inspection_run_canceled(run_id): return "canceled" with self._lock: goal = deepcopy(self._goal) if goal.get("sequence") == sequence and goal.get("state") in GOAL_TERMINAL_STATES: return str(goal.get("state")) time.sleep(0.2) return "timeout" def _inspection_run_canceled(self, run_id: int) -> bool: with self._lock: return self._inspection_run.get("run_id") != run_id or bool(self._inspection_run.get("cancel_requested")) or self._inspection_run.get("state") == "canceled" def _patch_inspection_run(self, run_id: int, patch: dict[str, Any]) -> None: with self._lock: if self._inspection_run.get("run_id") != run_id or self._inspection_run.get("state") not in INSPECTION_ACTIVE_STATES: return self._inspection_run.update(deepcopy(patch)) def _finish_inspection_run( self, run_id: int, state: str, error: str | None = None, error_detail: dict[str, Any] | None = None, ) -> None: with self._lock: if self._inspection_run.get("run_id") != run_id: return self._inspection_run["state"] = state self._inspection_run["cancel_requested"] = state == "canceled" self._inspection_run["error"] = error if error_detail: self._inspection_run["error_detail"] = error_detail.get("message") self._inspection_run["failure_category"] = error_detail.get("category") self._inspection_run["failure_next_step"] = error_detail.get("next_step") self._inspection_run["diagnostics"] = deepcopy(error_detail) else: self._inspection_run.pop("error_detail", None) self._inspection_run.pop("failure_category", None) self._inspection_run.pop("failure_next_step", None) self._inspection_run.pop("diagnostics", None) self._inspection_run["operator_required"] = state == "failed" self._inspection_run["finished_at_unix_s"] = now_s() self._inspection_run["updated_at_unix_s"] = now_s() def _inspection_run_view(self, inspection_run: dict[str, Any]) -> dict[str, Any]: state = inspection_run.get("state") dwell_duration_s = max(0.0, float(inspection_run.get("dwell_duration_s") or INSPECTION_DWELL_DURATION_S)) dwell_started_at = inspection_run.get("dwell_started_at_unix_s") if state == "completed": inspection_run["dwell_duration_s"] = dwell_duration_s inspection_run["dwell_elapsed_s"] = dwell_duration_s inspection_run["dwell_remaining_s"] = 0.0 inspection_run["dwell_progress_ratio"] = 1.0 if dwell_duration_s > 0.0 else 1.0 return inspection_run if state not in {"arrived", "dwelling"}: return inspection_run if not dwell_started_at: inspection_run["dwell_duration_s"] = dwell_duration_s inspection_run.setdefault("dwell_elapsed_s", 0.0) inspection_run.setdefault("dwell_remaining_s", dwell_duration_s) inspection_run.setdefault("dwell_progress_ratio", 0.0) return inspection_run elapsed = max(0.0, now_s() - float(dwell_started_at)) remaining = max(0.0, dwell_duration_s - elapsed) ratio = 1.0 if dwell_duration_s <= 0.0 else min(1.0, elapsed / dwell_duration_s) inspection_run["dwell_duration_s"] = dwell_duration_s inspection_run["dwell_elapsed_s"] = elapsed inspection_run["dwell_remaining_s"] = remaining inspection_run["dwell_progress_ratio"] = ratio return inspection_run def _inspection_waypoint_label(self, index: int) -> str: if index <= 0: return "已到第 1 个点(初始点)" return f"已到第 {index + 1} 个点" def _inspection_waypoint_check( self, waypoint: dict[str, Any], last_pose: dict[str, Any] | None, stable_since: float | None, required_stable_s: float, position_tolerance_m: float | None = None, ) -> tuple[dict[str, Any], dict[str, Any] | None, float | None]: position_tolerance_m = INSPECTION_POSITION_TOLERANCE_M if position_tolerance_m is None else max(0.01, float(position_tolerance_m)) with self._lock: pose = deepcopy(self._pose) current_time = now_s() latest_check: dict[str, Any] = { "ready": False, "fresh": False, "in_position": False, "in_yaw": False, "stopped": False, "distance_m": None, "yaw_error_deg": None, "pose_age_s": None, "linear_speed_mps": 0.0, "yaw_rate_deg_s": 0.0, "stable_for_s": 0.0, "required_stable_s": required_stable_s, "position_tolerance_m": position_tolerance_m, "yaw_tolerance_deg": INSPECTION_YAW_TOLERANCE_DEG, "pose_freshness_limit_s": INSPECTION_POSE_FRESHNESS_S, "max_linear_speed_mps": INSPECTION_MAX_LINEAR_SPEED_MPS, "max_yaw_rate_deg_s": INSPECTION_MAX_YAW_RATE_DEG_S, "reason": "定位数据不可用", "checked_at_unix_s": current_time, } if not pose: return latest_check, None, None pose_age_s = max(0.0, current_time - float(pose.get("received_at_unix_s", 0.0))) distance_m = math.hypot(float(pose["x"]) - float(waypoint["x"]), float(pose["y"]) - float(waypoint["y"])) signed_yaw_error_deg = normalize_yaw_deg(float(waypoint["yaw_deg"]) - float(pose["yaw_deg"])) yaw_error_deg = abs(signed_yaw_error_deg) linear_speed_mps = 0.0 yaw_rate_deg_s = 0.0 if last_pose and current_time > float(last_pose.get("_checked_at_unix_s", 0.0)): dt = current_time - float(last_pose["_checked_at_unix_s"]) linear_speed_mps = math.hypot( float(pose["x"]) - float(last_pose["x"]), float(pose["y"]) - float(last_pose["y"]), ) / max(dt, 0.001) yaw_rate_deg_s = abs(normalize_yaw_deg(float(pose["yaw_deg"]) - float(last_pose["yaw_deg"]))) / max(dt, 0.001) fresh = pose_age_s <= INSPECTION_POSE_FRESHNESS_S in_position = distance_m <= position_tolerance_m in_yaw = yaw_error_deg <= INSPECTION_YAW_TOLERANCE_DEG stopped = linear_speed_mps <= INSPECTION_MAX_LINEAR_SPEED_MPS and yaw_rate_deg_s <= INSPECTION_MAX_YAW_RATE_DEG_S if fresh and in_position and in_yaw and stopped: stable_since = stable_since or current_time else: stable_since = None stable_for_s = 0.0 if stable_since is None else max(0.0, current_time - stable_since) ready = fresh and in_position and in_yaw and stopped and stable_for_s >= max(0.0, required_stable_s) if not fresh: reason = "\u5b9a\u4f4d\u6570\u636e\u5df2\u8fc7\u671f" elif not in_position: reason = "\u4f4d\u7f6e\u504f\u5dee\u8d85\u51fa\u9608\u503c" elif not in_yaw: reason = "\u671d\u5411\u504f\u5dee\u8d85\u51fa\u9608\u503c" elif not stopped: reason = "\u673a\u5668\u4eba\u5c1a\u672a\u505c\u7a33" elif stable_for_s < max(0.0, required_stable_s): reason = f"等待稳定保持 {stable_for_s:.1f}/{max(0.0, required_stable_s):.1f}s" else: reason = None latest_check.update( { "ready": ready, "fresh": fresh, "in_position": in_position, "in_yaw": in_yaw, "stopped": stopped, "distance_m": distance_m, "yaw_error_deg": yaw_error_deg, "signed_yaw_error_deg": signed_yaw_error_deg, "pose_age_s": pose_age_s, "linear_speed_mps": linear_speed_mps, "yaw_rate_deg_s": yaw_rate_deg_s, "stable_for_s": stable_for_s, "reason": reason, } ) pose_snapshot = deepcopy(pose) pose_snapshot["_checked_at_unix_s"] = current_time return latest_check, pose_snapshot, stable_since def _navigation_yaw_for_position_goal(self, waypoint: dict[str, Any]) -> float: with self._lock: pose = deepcopy(self._pose) if pose and now_s() - float(pose.get("received_at_unix_s", 0.0)) <= INSPECTION_POSE_FRESHNESS_S: return float(pose["yaw_deg"]) return float(waypoint["yaw_deg"]) @staticmethod def _heading_to_waypoint(current_waypoint: dict[str, Any], next_waypoint: dict[str, Any]) -> float: dx = float(next_waypoint["x"]) - float(current_waypoint["x"]) dy = float(next_waypoint["y"]) - float(current_waypoint["y"]) if math.hypot(dx, dy) < 0.05: return float(next_waypoint.get("yaw_deg", current_waypoint.get("yaw_deg", 0.0))) return math.degrees(math.atan2(dy, dx)) def _publish_yaw_align_command(self, yaw_error_deg: float) -> float: twist = Twist() abs_error = abs(yaw_error_deg) if abs_error <= INSPECTION_YAW_TOLERANCE_DEG: self.cmd_vel_nav_pub.publish(twist) self.cmd_vel_pub.publish(twist) return 0.0 max_rate = max(INSPECTION_YAW_ALIGN_MIN_RATE_RAD_S, INSPECTION_YAW_ALIGN_MAX_RATE_RAD_S) min_rate = min(INSPECTION_YAW_ALIGN_MIN_RATE_RAD_S, max_rate) slowdown = max(INSPECTION_YAW_TOLERANCE_DEG, INSPECTION_YAW_ALIGN_SLOWDOWN_DEG) ratio = min(1.0, max(0.0, (abs_error - INSPECTION_YAW_TOLERANCE_DEG) / max(0.001, slowdown - INSPECTION_YAW_TOLERANCE_DEG))) rate = min_rate + (max_rate - min_rate) * ratio twist.angular.z = math.copysign(rate, yaw_error_deg) # chain as Nav2. Publishing them directly to /cmd_vel races with the # velocity_smoother final output and can be overwritten by zero commands. self.cmd_vel_nav_pub.publish(twist) return math.degrees(twist.angular.z) def _align_waypoint_yaw( self, run_id: int, sequence: int, index: int, waypoint: dict[str, Any], position_tolerance_m: float | None = None, status_text: str | None = None, ) -> tuple[dict[str, Any] | None, str | None]: deadline = now_s() + max(1.0, INSPECTION_YAW_ALIGN_TIMEOUT_S) stable_since: float | None = None last_pose: dict[str, Any] | None = None latest_check: dict[str, Any] | None = None while now_s() < deadline: if self._inspection_run_canceled(run_id): return latest_check, "canceled" latest_check, pose_snapshot, stable_since = self._inspection_waypoint_check( waypoint, last_pose, stable_since, INSPECTION_ARRIVAL_STABLE_HOLD_S, position_tolerance_m, ) last_pose = pose_snapshot yaw_error = latest_check.get("signed_yaw_error_deg") command_rate_deg_s = 0.0 if latest_check.get("fresh") and latest_check.get("in_position") and yaw_error is not None: command_rate_deg_s = self._publish_yaw_align_command(float(yaw_error)) else: self._publish_zero_velocity_burst() latest_check["yaw_aligning"] = True latest_check["yaw_command_deg_s"] = command_rate_deg_s self._patch_inspection_run( run_id, { "arrival_check": deepcopy(latest_check), "status_text": status_text or f"{self._inspection_waypoint_label(index)},正在对准朝向", "updated_at_unix_s": now_s(), }, ) if latest_check.get("ready"): self._publish_zero_velocity_burst() return latest_check, None time.sleep(0.1) self._publish_zero_velocity_burst() return latest_check, "timeout" def _wait_for_waypoint_arrival( self, run_id: int, sequence: int, index: int, waypoint: dict[str, Any], timeout_s: float, position_tolerance_m: float | None = None, ) -> tuple[dict[str, Any] | None, str | None]: deadline = now_s() + max(1.0, timeout_s) stable_since: float | None = None last_pose: dict[str, Any] | None = None latest_check: dict[str, Any] | None = None while now_s() < deadline: if self._inspection_run_canceled(run_id): return None, "canceled" latest_check, pose_snapshot, stable_since = self._inspection_waypoint_check( waypoint, last_pose, stable_since, INSPECTION_ARRIVAL_STABLE_HOLD_S, position_tolerance_m, ) last_pose = pose_snapshot self._patch_inspection_run( run_id, { "arrival_check": deepcopy(latest_check), "updated_at_unix_s": now_s(), }, ) if latest_check.get("ready"): return latest_check, None time.sleep(0.1) return latest_check, "timeout" def _run_inspection_dwell( self, run_id: int, sequence: int, index: int, waypoint: dict[str, Any], dwell_message: str, ) -> tuple[bool, str | None, dict[str, Any] | None]: dwell_duration_s = INSPECTION_DWELL_DURATION_S dwell_started_at = now_s() self._patch_inspection_run( run_id, { "state": "dwelling", "status_text": dwell_message, "dwell_started_at_unix_s": dwell_started_at, "dwell_duration_s": dwell_duration_s, "dwell_elapsed_s": 0.0, "dwell_remaining_s": dwell_duration_s, "dwell_progress_ratio": 0.0, "updated_at_unix_s": now_s(), }, ) deadline = dwell_started_at + dwell_duration_s stable_since: float | None = None last_pose: dict[str, Any] | None = None latest_check: dict[str, Any] | None = None while now_s() < deadline: if self._inspection_run_canceled(run_id): return False, "canceled", latest_check latest_check, pose_snapshot, stable_since = self._inspection_waypoint_check( waypoint, last_pose, stable_since, 0.0, ) last_pose = pose_snapshot if latest_check: dwell_ready = bool(latest_check.get("fresh")) and bool(latest_check.get("stopped")) latest_check["ready"] = dwell_ready if dwell_ready: latest_check["reason"] = None elif not latest_check.get("fresh"): latest_check["reason"] = "\u5b9a\u4f4d\u6570\u636e\u5df2\u8fc7\u671f" else: latest_check["reason"] = "\u673a\u5668\u4eba\u5c1a\u672a\u505c\u7a33" if latest_check and not latest_check.get("fresh"): return False, "\u5b9a\u4f4d\u6570\u636e\u5df2\u8fc7\u671f", latest_check time.sleep(0.1) self._patch_inspection_run( run_id, { "dwell_elapsed_s": dwell_duration_s, "dwell_remaining_s": 0.0, "dwell_progress_ratio": 1.0, "updated_at_unix_s": now_s(), }, ) return True, None, latest_check def _goal_response_callback(self, future: Any, sequence: int) -> None: try: goal_handle = future.result() except Exception as exc: with self._lock: if sequence == self._goal_sequence: self._current_goal_handle = None self._current_goal_sequence = None self._goal = {"state": "failed", "sequence": sequence, "error": str(exc), "updated_at_unix_s": now_s()} return if not goal_handle.accepted: with self._lock: if sequence == self._goal_sequence: self._current_goal_handle = None self._current_goal_sequence = None self._goal = {"state": "rejected", "sequence": sequence, "updated_at_unix_s": now_s()} return with self._lock: if sequence != self._goal_sequence: should_cancel_stale_goal = True else: should_cancel_stale_goal = False self._current_goal_handle = goal_handle self._current_goal_sequence = sequence self._goal["state"] = "accepted" self._goal["updated_at_unix_s"] = now_s() if should_cancel_stale_goal: try: goal_handle.cancel_goal_async() except Exception as exc: self.get_logger().warning(f"Failed to cancel stale goal response: {exc}") return result_future = goal_handle.get_result_async() result_future.add_done_callback(lambda done, goal_sequence=sequence: self._goal_result_callback(done, goal_sequence)) def _goal_result_callback(self, future: Any, sequence: int) -> None: try: result = future.result() state = status_name(int(result.status)) except Exception as exc: state = "failed" error = str(exc) else: error = None failure_detail = None if state in {"aborted", "failed", "unknown"}: failure_detail = self._diagnose_navigation_failure(state, error) target = None with self._lock: if sequence == self._current_goal_sequence: self._current_goal_handle = None self._current_goal_sequence = None if sequence != self._goal_sequence: return self._goal["state"] = state self._goal["error"] = error if failure_detail: self._goal["error_detail"] = failure_detail.get("message") self._goal["failure_category"] = failure_detail.get("category") self._goal["failure_next_step"] = failure_detail.get("next_step") self._goal["diagnostics"] = deepcopy(failure_detail) else: self._goal.pop("error_detail", None) self._goal.pop("failure_category", None) self._goal.pop("failure_next_step", None) self._goal.pop("diagnostics", None) self._goal["updated_at_unix_s"] = now_s() target = deepcopy(self._goal.get("target")) def _publish_zero_velocity_burst(self) -> None: zero = Twist() for _ in range(5): self.cmd_vel_pub.publish(zero) self.cmd_vel_nav_pub.publish(zero) time.sleep(0.04) def _cancel_callback(self, future: Any, sequence: int | None = None) -> None: try: cancel_response = future.result() cancel_count = len(cancel_response.goals_canceling) except Exception as exc: with self._lock: self._goal = {"state": "stop_failed", "error": str(exc), "updated_at_unix_s": now_s()} return with self._lock: if sequence is not None and sequence == self._current_goal_sequence and not cancel_count: self._current_goal_handle = None self._current_goal_sequence = None self._goal = {"state": "canceling" if cancel_count else "stopped", "updated_at_unix_s": now_s()} def _load_fallback_map(self) -> dict[str, Any] | None: with self._lock: if self._fallback_map: return deepcopy(self._fallback_map) try: payload = load_map_yaml(self.map_yaml) except Exception as exc: self.get_logger().warning(f"Failed to load map fallback {self.map_yaml}: {exc}") return None with self._lock: self._fallback_map = payload return deepcopy(payload) @staticmethod def _nav_launch_pid() -> int | None: try: result = subprocess.run( ["pgrep", "-af", "go2_navigation2 go2_nav2.launch.py"], check=False, capture_output=True, text=True, timeout=1.0, ) except Exception: return None for line in result.stdout.splitlines(): parts = line.strip().split(maxsplit=1) if len(parts) != 2: continue pid_text, command = parts is_ros2_launch = ( "/bin/ros2 launch go2_navigation2 go2_nav2.launch.py" in command or command.startswith("ros2 launch go2_navigation2 go2_nav2.launch.py") ) if not is_ros2_launch: continue try: return int(pid_text) except ValueError: continue return None class CameraSampler: def __init__(self, iface: str, interval_s: float = 0.18, video_url: str = "") -> None: self.iface = iface self.interval_s = interval_s self.video_url = video_url or os.environ.get("GO2_DASHBOARD_VIDEO_URL", "") self._condition = threading.Condition() self._frame: bytes | None = None self._frame_id = 0 self._updated_at = 0.0 self._error: str | None = None self._source = "http" if self.video_url else "sdk" self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def _run(self) -> None: if self.video_url: self._run_http() else: self._run_sdk() def _run_http(self) -> None: import urllib.request while True: try: req = urllib.request.Request(self.video_url) with urllib.request.urlopen(req, timeout=10.0) as resp: content_type = resp.headers.get("Content-Type", "") if "multipart" in content_type: self._read_mjpeg_stream(resp) else: data = resp.read() if data.startswith(b"\xff\xd8"): with self._condition: self._frame = data self._frame_id += 1 self._updated_at = now_s() self._error = None self._condition.notify_all() time.sleep(self.interval_s) except Exception as exc: with self._condition: self._error = f"HTTP video fetch failed: {exc}" self._condition.notify_all() time.sleep(2.0) def _read_mjpeg_stream(self, resp) -> None: buf = b"" while True: chunk = resp.read(8192) if not chunk: break buf += chunk while True: start = buf.find(b"\xff\xd8") if start == -1: buf = buf[-1:] break end = buf.find(b"\xff\xd9", start + 2) if end == -1: break frame = buf[start:end + 2] buf = buf[end + 2:] with self._condition: self._frame = frame self._frame_id += 1 self._updated_at = now_s() self._error = None self._condition.notify_all() def _run_sdk(self) -> None: try: from unitree_sdk2py.core.channel import ChannelFactoryInitialize from unitree_sdk2py.go2.video.video_client import VideoClient ChannelFactoryInitialize(0, self.iface) client = VideoClient() client.SetTimeout(3.0) client.Init() except Exception as exc: with self._condition: self._error = f"Camera SDK init failed: {exc}" self._condition.notify_all() return while True: try: code, data = client.GetImageSample() if code == 0 and data: frame = bytes(data) if frame.startswith(b"\xff\xd8"): with self._condition: self._frame = frame self._frame_id += 1 self._updated_at = now_s() self._error = None self._condition.notify_all() else: with self._condition: self._error = "Camera SDK returned non-JPEG data" else: with self._condition: self._error = f"Camera SDK returned code {code}" time.sleep(self.interval_s) except Exception as exc: with self._condition: self._error = f"Camera sample failed: {exc}" self._condition.notify_all() time.sleep(1.0) def wait_for_frame(self, previous_id: int, timeout_s: float) -> tuple[int, bytes] | None: deadline = now_s() + timeout_s with self._condition: while self._frame_id <= previous_id: remaining = deadline - now_s() if remaining <= 0: return None self._condition.wait(remaining) if self._frame is None: return None return self._frame_id, self._frame def wait_for_fresh_frame(self, timeout_s: float, max_age_s: float) -> tuple[int, bytes, float] | None: deadline = now_s() + timeout_s with self._condition: while True: if self._frame is not None and self._updated_at and now_s() - self._updated_at <= max_age_s: return self._frame_id, bytes(self._frame), self._updated_at remaining = deadline - now_s() if remaining <= 0: return None self._condition.wait(remaining) def status(self) -> dict[str, Any]: with self._condition: age_s = None if not self._updated_at else max(0.0, now_s() - self._updated_at) source = self.video_url if self._source == "http" else "unitree_sdk2py.VideoClient.GetImageSample" return { "video_url": "/api/video", "source": source, "iface": self.iface, "available": self._frame is not None and (age_s is None or age_s < 3.0), "frame_id": self._frame_id, "frame_age_s": age_s, "error": self._error, } def read_map_yaml_values(map_yaml: Path) -> dict[str, str]: values: dict[str, str] = {} for raw_line in map_yaml.read_text(encoding="utf-8").splitlines(): line = raw_line.split("#", 1)[0].strip() if not line or ":" not in line: continue key, value = line.split(":", 1) values[key.strip()] = value.strip() return values def map_image_path(map_yaml: Path, values: dict[str, str] | None = None) -> Path: values = values or read_map_yaml_values(map_yaml) image_value = values.get("image") if not image_value: raise ValueError("map yaml does not contain image") image_path = Path(image_value) if not image_path.is_absolute(): image_path = map_yaml.parent / image_path return image_path def map_file_revision(map_yaml: Path) -> int: values = read_map_yaml_values(map_yaml) image_path = map_image_path(map_yaml, values) return int(max(map_yaml.stat().st_mtime_ns, image_path.stat().st_mtime_ns)) def load_map_yaml(map_yaml: Path) -> dict[str, Any]: values = read_map_yaml_values(map_yaml) image_path = map_image_path(map_yaml, values) resolution = float(values.get("resolution", "0.05")) origin_raw = values.get("origin", "[0, 0, 0]").strip() origin_parts = [float(item.strip()) for item in origin_raw.strip("[]").split(",")] while len(origin_parts) < 3: origin_parts.append(0.0) width, height, pixels = read_pgm(image_path) cells = [0] * (width * height) for src_y in range(height): map_y = height - 1 - src_y for x in range(width): value = pixels[src_y * width + x] if value >= 250: occ = 0 elif 190 <= value <= 220: occ = -1 elif value <= 100: occ = 100 else: occ = -1 cells[map_y * width + x] = occ return { "name": "maps/current", "revision": map_file_revision(map_yaml), "frame_id": "map", "resolution": resolution, "width": width, "height": height, "origin": {"x": origin_parts[0], "y": origin_parts[1], "yaw_deg": math.degrees(origin_parts[2])}, "cells": cells, "source": str(map_yaml), "received_at_unix_s": now_s(), } def read_pgm(path: Path) -> tuple[int, int, bytes]: data = path.read_bytes() index = 0 def token() -> bytes: nonlocal index while index < len(data): byte = data[index] if byte == ord("#"): while index < len(data) and data[index] not in (10, 13): index += 1 elif chr(byte).isspace(): index += 1 else: break start = index while index < len(data) and not chr(data[index]).isspace(): index += 1 return data[start:index] magic = token() if magic != b"P5": raise ValueError(f"unsupported PGM format: {magic!r}") width = int(token()) height = int(token()) max_value = int(token()) if max_value > 255: raise ValueError("16-bit PGM is not supported") while index < len(data) and chr(data[index]).isspace(): index += 1 pixels = data[index : index + width * height] if len(pixels) != width * height: raise ValueError("PGM pixel data is truncated") return width, height, pixels class DashboardHandler(SimpleHTTPRequestHandler): server_version = "OpenSourceGo2Dashboard/1.0" def do_GET(self) -> None: parsed = urlparse(self.path) if parsed.path == "/api/pose_stream": self._stream_pose() return if parsed.path == "/api/script/patrol_executor/status": import subprocess pids = subprocess.run(["pgrep", "-f", "patrol_executor_node"], capture_output=True, text=True).stdout.split() self._send_json({"running": bool(pids)}) return if parsed.path == "/api/health": self._send_json({"ok": True, "name": "openSourceGo2Dashboard"}) return if parsed.path == "/api/video": self._stream_video() return if parsed.path == "/api/state": payload = self.server.nav_node.state() # type: ignore[attr-defined] payload["camera"] = self.server.camera_sampler.status() # type: ignore[attr-defined] self._send_json(payload) return if parsed.path == "/api/map": try: self._send_json(self.server.nav_node.map_payload()) # type: ignore[attr-defined] except Exception as exc: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) return if parsed.path == "/api/patrol-route": try: self._send_json(self.server.nav_node.load_patrol_route()) except Exception as exc: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) return if parsed.path == "/api/inspection-routes/default": try: self._send_json(self.server.nav_node.load_inspection_route()) # type: ignore[attr-defined] except Exception as exc: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) return if parsed.path == "/api/v1/dashboardStates/current": payload = self.server.nav_node.state() # type: ignore[attr-defined] payload["camera"] = self.server.camera_sampler.status() # type: ignore[attr-defined] payload["legacy_warning"] = "Refresh the page; this browser is still using an old dashboard script." self._send_json(payload) return if parsed.path == "/api/v1/events": self._send_json({"events": [], "legacy_warning": "Refresh the page to load the current dashboard."}) return if parsed.path.startswith("/api/v1/"): self._send_error(HTTPStatus.GONE, "Legacy GO2 dashboard API has been removed") return if parsed.path == "/": self.path = "/index.html" return super().do_GET() def end_headers(self) -> None: if not self.path.startswith("/api/video"): self.send_header("Cache-Control", "no-store") super().end_headers() def do_PATCH(self) -> None: parsed = urlparse(self.path) try: body = read_json_body(self) if parsed.path == "/api/patrol-route": result = self.server.nav_node.save_patrol_route(body) self._send_json(result) return if parsed.path == "/api/inspection-routes/default": query = parse_qs(parsed.query) update_mask = ",".join(query.get("update_mask", [])) payload = self.server.nav_node.patch_inspection_route(body, update_mask) # type: ignore[attr-defined] else: self._send_error(HTTPStatus.NOT_FOUND, "Resource was not found") return except ValueError as exc: self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) return except RuntimeError as exc: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) return except Exception as exc: self._send_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) return self._send_json(payload) def do_POST(self) -> None: parsed = urlparse(self.path) try: body = read_json_body(self) if parsed.path == "/api/initial_pose": payload = self.server.nav_node.publish_initial_pose(body) # type: ignore[attr-defined] elif parsed.path == "/api/goal": payload = self.server.nav_node.navigate_to_pose(body) # type: ignore[attr-defined] elif parsed.path == "/api/navigation/stop": payload = self.server.nav_node.stop_navigation() self._send_json(payload) elif parsed.path == "/api/stop": payload = self.server.nav_node.stop_motion() # type: ignore[attr-defined] elif parsed.path == "/api/navigation/start": payload = self.server.nav_node.start_navigation() # type: ignore[attr-defined] elif parsed.path == "/api/localization-checks/current:confirm": payload = self.server.nav_node.confirm_localization() # type: ignore[attr-defined] elif parsed.path == "/api/inspection-routes/default:start": payload = self.server.nav_node.start_inspection_route() # type: ignore[attr-defined] elif parsed.path == "/api/script/start_nav": import subprocess, threading repo = str(self.server.nav_node.repo_root) # type: ignore[attr-defined] def _run_nav(): subprocess.Popen(["bash", f"{repo}/scripts/go2_start_nav.sh"], stdout=open(f"{repo}/log/start_nav.log", "a"), stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) threading.Thread(target=_run_nav, daemon=True).start() payload = {"ok": True, "msg": "go2_start_nav.sh started"} elif parsed.path == "/api/script/run_patrol": import subprocess nav_node = self.server.nav_node # type: ignore[attr-defined] robot_ros = nav_node._robot_ros_link_state() if not robot_ros["sources_fresh"]: stale = ", ".join(robot_ros["stale_sources"]) raise RuntimeError(f"机器人 ROS 通信未就绪,缺少数据源: {stale}") if not nav_node.nav_client.wait_for_server(timeout_sec=1.0): raise RuntimeError("Nav2 action server 未就绪,请先启动导航") repo = str(nav_node.repo_root) log_dir = Path(repo) / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "run_patrol.log" env = os.environ.copy() if "DISPLAY" not in env: env["DISPLAY"] = ":0" if "WAYLAND_DISPLAY" not in env: env["WAYLAND_DISPLAY"] = "wayland-0" env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" subprocess.Popen( ["bash", f"{repo}/scripts/go2_run_patrol.sh"], cwd=repo, env=env, stdout=log_file.open("ab"), stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True ) payload = {"ok": True, "msg": "巡检已启动"} elif parsed.path == "/api/script/patrol_executor/toggle": import subprocess, signal repo = str(self.server.nav_node.repo_root) # type: ignore[attr-defined] pids = subprocess.run(["pgrep", "-f", "patrol_executor_node"], capture_output=True, text=True).stdout.split() if pids: for pid in pids: try: __import__("os").kill(int(pid), signal.SIGTERM) except Exception: pass payload = {"ok": True, "running": False, "msg": "巡检服务已停止"} else: log_dir = Path(repo) / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "patrol_executor.log" env = os.environ.copy() if "DISPLAY" not in env: env["DISPLAY"] = ":0" if "WAYLAND_DISPLAY" not in env: env["WAYLAND_DISPLAY"] = "wayland-0" env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" subprocess.Popen( ["bash", f"{repo}/scripts/go2_start_patrol_executor.sh"], cwd=repo, env=env, stdout=log_file.open("ab"), stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True ) payload = {"ok": True, "running": True, "msg": "巡检服务已启动"} elif parsed.path == "/api/script/patrol_executor/status": import subprocess repo = str(self.server.nav_node.repo_root) # type: ignore[attr-defined] pids = subprocess.run(["pgrep", "-f", "patrol_executor_node"], capture_output=True, text=True).stdout.split() payload = {"running": bool(pids)} elif parsed.path == "/api/inspection-runs/current:cancel": payload = self.server.nav_node.cancel_inspection_run() # type: ignore[attr-defined] elif parsed.path.startswith("/api/v1/"): self._send_error(HTTPStatus.GONE, "Legacy GO2 dashboard API has been removed") return else: self._send_error(HTTPStatus.NOT_FOUND, "Resource was not found") return except ValueError as exc: self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) return except RuntimeError as exc: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) return except Exception as exc: self._send_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) return self._send_json(payload) def _send_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_error(self, status: HTTPStatus, message: str) -> None: self._send_json({"error": {"code": int(status), "status": status.name, "message": message}}, status) def _stream_pose(self) -> None: import math, time self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-store") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() try: while True: pose = self.server.nav_node._pose # type: ignore[attr-defined] if pose: x = float(pose.get("x", 0)) y = float(pose.get("y", 0)) yaw_deg = float(pose.get("yaw_deg", 0)) yaw_rad = math.radians(yaw_deg) msg = "x=%.3f y=%.3f yaw=%.3f rad (%.1f deg)" % (x, y, yaw_rad, yaw_deg) self.wfile.write(("data: " + msg + chr(10) + chr(10)).encode("utf-8")) self.wfile.flush() time.sleep(0.5) except (BrokenPipeError, ConnectionResetError): return def _stream_video(self) -> None: sampler = self.server.camera_sampler # type: ignore[attr-defined] first = sampler.wait_for_frame(0, 6.0) if first is None: self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Camera frame is not available") return boundary = "go2frame" self.send_response(HTTPStatus.OK) self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={boundary}") self.send_header("Cache-Control", "no-store") self.end_headers() frame_id, frame = first try: while True: self._write_mjpeg_frame(boundary, frame) next_frame = sampler.wait_for_frame(frame_id, 5.0) if next_frame is None: continue frame_id, frame = next_frame except (BrokenPipeError, ConnectionResetError): return def _write_mjpeg_frame(self, boundary: str, frame: bytes) -> None: header = ( f"--{boundary}\r\n" "Content-Type: image/jpeg\r\n" f"Content-Length: {len(frame)}\r\n\r\n" ).encode("ascii") self.wfile.write(header) self.wfile.write(frame) self.wfile.write(b"\r\n") self.wfile.flush() def log_message(self, format: str, *args: Any) -> None: print(f"{self.address_string()} - {format % args}", flush=True) def main() -> int: parser = argparse.ArgumentParser(description="Open-source Go2 Nav2 dashboard") parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=8090) parser.add_argument("--map-yaml", default=str(Path.home() / "go2_maps" / "competition_map.yaml")) parser.add_argument("--repo-root", default=str(Path.home() / "go2_robot_system")) parser.add_argument("--static-dir", default=str(Path(__file__).resolve().parent / "static")) parser.add_argument("--nav-log-dir", default=str(Path.home() / "go2_robot_system" / "log" / "go2_nav")) parser.add_argument("--camera-iface", default=os.environ.get("GO2_CAMERA_IFACE", "ens160")) parser.add_argument( "--pose-config", default=str(Path.home() / "go2_robot_system" / "go2_dashboard" / "config" / "pose_pipeline.json"), ) parser.add_argument( "--waypoint-action-config", default=str(Path.home() / "go2_robot_system" / "go2_dashboard" / "config" / "waypoint_action.json"), ) args = parser.parse_args() static_dir = Path(args.static_dir).resolve() os.chdir(static_dir) rclpy.init() nav_node = OpenSourceNavNode( Path(args.map_yaml).expanduser(), Path(args.repo_root).expanduser(), Path(args.nav_log_dir).expanduser(), Path(args.pose_config).expanduser(), Path(args.waypoint_action_config).expanduser(), ) executor = MultiThreadedExecutor(num_threads=2) executor.add_node(nav_node) spin_thread = threading.Thread(target=executor.spin, daemon=True) spin_thread.start() camera_sampler = CameraSampler(args.camera_iface) nav_node.camera_sampler = camera_sampler class Server(ThreadingHTTPServer): daemon_threads = True httpd = Server((args.host, args.port), DashboardHandler) httpd.nav_node = nav_node # type: ignore[attr-defined] httpd.camera_sampler = camera_sampler # type: ignore[attr-defined] stopping = threading.Event() def handle_signal(signum: int, _frame: Any) -> None: if stopping.is_set(): return stopping.set() print(f"Received signal {signum}, shutting down", flush=True) threading.Thread(target=httpd.shutdown, daemon=True).start() signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) print(f"Open-source Go2 dashboard listening on http://{args.host}:{args.port}", flush=True) try: httpd.serve_forever() finally: httpd.server_close() executor.shutdown() nav_node.destroy_node() rclpy.shutdown() return 0 if __name__ == "__main__": raise SystemExit(main())