3438 lines
159 KiB
Python
3438 lines
159 KiB
Python
#!/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())
|