diff --git a/.gitignore b/.gitignore index aedee04af7..e79a4bf5a3 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ htmlcov/ # Memory2 autorecord recording*.db + +# DogOps local run state and hardware logs +/.dogops/ diff --git a/dimos/agents/mcp/mcp_server.py b/dimos/agents/mcp/mcp_server.py index dbd31f8d87..7f6f144c45 100644 --- a/dimos/agents/mcp/mcp_server.py +++ b/dimos/agents/mcp/mcp_server.py @@ -19,7 +19,7 @@ import json import os import time -from typing import TYPE_CHECKING, Any +from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -31,13 +31,10 @@ from dimos.agents.annotation import skill from dimos.agents.mcp import tool_stream from dimos.core.core import rpc -from dimos.core.module import Module +from dimos.core.module import Module, SkillInfo from dimos.core.rpc_client import RpcCall, RPCClient from dimos.utils.logging_config import setup_logger -if TYPE_CHECKING: - from dimos.core.module import SkillInfo - logger = setup_logger() @@ -280,7 +277,7 @@ def on_system_modules(self, modules: list[RPCClient]) -> None: # TODO: this is a bit hacky, also not thread-safe assert self.rpc is not None app.state.skills = [ - skill_info for module in modules for skill_info in (module.get_skills() or []) + skill_info for module in modules for skill_info in _module_class_skills(module) ] app.state.rpc_calls = { skill_info.func_name: RpcCall( @@ -342,3 +339,40 @@ def _start_server(self, port: int | None = None) -> None: loop = self._loop assert loop is not None self._serve_future = asyncio.run_coroutine_threadsafe(server.serve(), loop) + + +def _module_class_skills(module: RPCClient) -> list[SkillInfo]: + """Return skill metadata without round-tripping to every remote module. + + Startup discovery runs inside the MCP server RPC call. Calling each + module's remote get_skills RPC from there can block module startup long + enough that MCP comes up with no tools. The proxy already carries the + module class, and skill schemas are static, so deriving metadata locally is + enough for discovery while tool execution still uses RPC. + """ + from langchain_core.tools import tool + + actor_class = getattr(module, "actor_class", None) + if actor_class is None: + return [] + + skills: list[SkillInfo] = [] + for name in dir(actor_class): + attr = getattr(actor_class, name, None) + if not callable(attr) or not hasattr(attr, "__skill__"): + continue + schema = tool(attr).args_schema.model_json_schema() + properties = schema.get("properties") + if isinstance(properties, dict): + properties.pop("self", None) + required = schema.get("required") + if isinstance(required, list) and "self" in required: + schema["required"] = [item for item in required if item != "self"] + skills.append( + SkillInfo( + class_name=actor_class.__name__, + func_name=name, + args_schema=json.dumps(schema), + ) + ) + return skills diff --git a/dimos/experimental/dogops/__init__.py b/dimos/experimental/dogops/__init__.py new file mode 100644 index 0000000000..ed1ecae9c4 --- /dev/null +++ b/dimos/experimental/dogops/__init__.py @@ -0,0 +1,6 @@ +"""DogOps SiteOps Agent offline core.""" + +from dimos.experimental.dogops.config_loader import load_dogops_config +from dimos.experimental.dogops.mission_engine import run_offline_simulation + +__all__ = ["load_dogops_config", "run_offline_simulation"] diff --git a/dimos/experimental/dogops/blueprints.py b/dimos/experimental/dogops/blueprints.py new file mode 100644 index 0000000000..803b0370be --- /dev/null +++ b/dimos/experimental/dogops/blueprints.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from dimos.experimental.dogops.dashboard import DogOpsDashboardModule +from dimos.experimental.dogops.nav_eval import DogOpsNavEvalModule +from dimos.experimental.dogops.observation_module import DogOpsObservationModule +from dimos.experimental.dogops.skills import DogOpsSkillContainer + + +@dataclass(frozen=True) +class DogOpsBlueprintMetadata: + name: str + modules: tuple[str, ...] + robot_model: str = "unitree_go2" + requires_mcp_client: bool = False + fallback: bool = True + + +def build_unitree_go2_dogops_blueprint() -> object: + try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.agents.mcp.mcp_server import McpServer + from dimos.core.coordination.blueprints import autoconnect + from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2_markers + except ModuleNotFoundError: + return DogOpsBlueprintMetadata( + name="unitree-go2-dogops", + modules=( + "unitree_go2_markers", + "DogOpsObservationModule", + "DogOpsSkillContainer", + "McpServer", + "DogOpsDashboardModule", + "DogOpsNavEvalModule", + ), + ) + + return autoconnect( + unitree_go2_markers, + DogOpsObservationModule.blueprint(), + DogOpsSkillContainer.blueprint(), + DogOpsDashboardModule.blueprint(), + DogOpsNavEvalModule.blueprint(), + McpServer.blueprint(), + ).global_config(n_workers=12, robot_model="unitree_go2") + + +unitree_go2_dogops = build_unitree_go2_dogops_blueprint() diff --git a/dimos/experimental/dogops/cli.py b/dimos/experimental/dogops/cli.py new file mode 100644 index 0000000000..c1ac5ab9d7 --- /dev/null +++ b/dimos/experimental/dogops/cli.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from dimos.experimental.dogops.config_loader import ( + DEFAULT_MANIFEST, + DEFAULT_MISSION, + DEFAULT_POLICY, + DEFAULT_SITE, + load_dogops_config, +) +from dimos.experimental.dogops.dashboard import serve_dashboard +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.report import render_report_markdown +from dimos.experimental.dogops.store import DogOpsStore + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="dogops", description="DogOps offline CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + validate = subparsers.add_parser("validate", help="Validate DogOps YAML configs") + _add_config_args(validate) + + simulate = subparsers.add_parser("simulate", help="Run the offline DogOps mission") + _add_config_args(simulate) + simulate.add_argument("--out", default=".dogops/runs/latest") + + report = subparsers.add_parser("report", help="Regenerate a report from a run directory") + report.add_argument("--run", default=".dogops/runs/latest") + report.add_argument("--out", default=None) + + serve = subparsers.add_parser("serve", help="Serve a local dashboard for a run directory") + serve.add_argument("--run", default=".dogops/runs/latest") + serve.add_argument("--host", default="127.0.0.1") + serve.add_argument("--port", type=int, default=8765) + + return parser + + +def _add_config_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--site", default=str(DEFAULT_SITE)) + parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) + parser.add_argument("--mission", default=str(DEFAULT_MISSION)) + parser.add_argument("--policy", default=str(DEFAULT_POLICY)) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "validate": + config = load_dogops_config(args.site, args.manifest, args.mission, args.policy) + print( + "validated " + f"site={config.site.site_id} " + f"manifest={config.manifest.manifest_id} " + f"mission={config.mission.mission_id}" + ) + return 0 + + if args.command == "simulate": + state = run_offline_simulation( + site=args.site, + manifest=args.manifest, + mission=args.mission, + policy=args.policy, + out=args.out, + ) + print(f"run_id={state.run.id}") + print(f"state={getattr(state.run.state, 'value', state.run.state)}") + print(f"report={Path(args.out) / 'report.md'}") + return 0 + + if args.command == "report": + store = DogOpsStore.load_existing(args.run) + state = store.state + assert state is not None + content = render_report_markdown(state) + out_path = Path(args.out) if args.out else Path(args.run) / "report.md" + out_path.write_text(content, encoding="utf-8") + store.write_report(state.run.id) + print(f"report={out_path}") + return 0 + + if args.command == "serve": + serve_dashboard(args.run, host=args.host, port=args.port) + return 0 + + parser.error(f"unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dimos/experimental/dogops/config_loader.py b/dimos/experimental/dogops/config_loader.py new file mode 100644 index 0000000000..58a8cde22e --- /dev/null +++ b/dimos/experimental/dogops/config_loader.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from dimos.experimental.dogops.models import ( + DogOpsConfig, + Manifest, + MissionConfig, + PolicyConfig, + SiteConfig, +) + +DEFAULT_SITE = Path("examples/dogops/site_demo.yaml") +DEFAULT_MANIFEST = Path("examples/dogops/manifest_demo.yaml") +DEFAULT_POLICY = Path("examples/dogops/policy_demo.yaml") +DEFAULT_MISSION = Path("examples/dogops/mission_demo.yaml") + + +def _read_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(f"DogOps config not found: {path}") + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError(f"DogOps config must be a mapping: {path}") + return data + + +def load_site_config(path: str | Path = DEFAULT_SITE) -> SiteConfig: + return SiteConfig.model_validate(_read_yaml(Path(path))) + + +def load_manifest(path: str | Path = DEFAULT_MANIFEST) -> Manifest: + return Manifest.model_validate(_read_yaml(Path(path))) + + +def load_policy(path: str | Path = DEFAULT_POLICY) -> PolicyConfig: + return PolicyConfig.model_validate(_read_yaml(Path(path))) + + +def load_mission(path: str | Path = DEFAULT_MISSION) -> MissionConfig: + return MissionConfig.model_validate(_read_yaml(Path(path))) + + +def load_dogops_config( + site_path: str | Path = DEFAULT_SITE, + manifest_path: str | Path = DEFAULT_MANIFEST, + mission_path: str | Path = DEFAULT_MISSION, + policy_path: str | Path = DEFAULT_POLICY, +) -> DogOpsConfig: + site = load_site_config(site_path) + manifest = load_manifest(manifest_path) + policy = load_policy(policy_path) + mission = load_mission(mission_path) + return DogOpsConfig( + site=site, + manifest=manifest, + policy=policy, + mission=mission, + paths={ + "site": Path(site_path), + "manifest": Path(manifest_path), + "policy": Path(policy_path), + "mission": Path(mission_path), + }, + ) diff --git a/dimos/experimental/dogops/conftest.py b/dimos/experimental/dogops/conftest.py new file mode 100644 index 0000000000..8f0a5f3fc9 --- /dev/null +++ b/dimos/experimental/dogops/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from dimos.experimental.dogops.config_loader import load_site_config + + +@pytest.fixture +def dogops_site(): + return load_site_config() diff --git a/dimos/experimental/dogops/dashboard.py b/dimos/experimental/dogops/dashboard.py new file mode 100644 index 0000000000..a2202f2b8a --- /dev/null +++ b/dimos/experimental/dogops/dashboard.py @@ -0,0 +1,2405 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from concurrent.futures import TimeoutError as FutureTimeoutError +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import math +import os +from pathlib import Path +import secrets +import shlex +import shutil +import subprocess +import threading +import time +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + +from pydantic import ValidationError + +from dimos.experimental.dogops.dashboard_static import ( + build_map_data, + build_poi_data, + build_route_data, + write_dashboard_html, +) +from dimos.experimental.dogops.heatmap_runs import ( + gather_heatmap_run, + heatmap_snapshot_for_route_run, + latest_heatmap_snapshot, +) +from dimos.experimental.dogops.live_camera import DogOpsLiveCameraAdapter +from dimos.experimental.dogops.live_map import DogOpsLiveMapAdapter +from dimos.experimental.dogops.map_authoring import ( + EditableIncidentLocation, + EditableMapEntity, + EditableNoGoShape, + EditableRoute, + EditableTagBinding, + MapAuthoringState, + delete_entity, + delete_no_go_shape, + delete_route, + delete_tag_binding, + export_authoring_yaml, + load_map_authoring, + replace_entity, + replace_incident_location, + replace_no_go_shape, + replace_route, + replace_tag_binding, + save_map_authoring, + select_route, + publish_no_go_constraints, + validation_error_message, +) +from dimos.experimental.dogops.qr_cargo import ( + append_qr_event, + get_latest_qr_events, + get_qr_event, + load_qr_events, + qr_events_path, +) +from dimos.experimental.dogops.route_executor import ( + DogOpsRouteExecutor, + load_route_execution, + request_route_stop, +) +from dimos.experimental.dogops.route_run_store import RouteRunStore +from dimos.experimental.dogops.store import DogOpsStore + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.module import Module +except ModuleNotFoundError: + + class Module: + @classmethod + def blueprint(cls, **kwargs: object) -> dict[str, object]: + return {"module": cls.__name__, "kwargs": kwargs} + + +DEFAULT_JOG_DURATION_S = 0.35 +MAX_JOG_DURATION_S = 2.00 +MAX_LINEAR_SPEED = 0.65 +MAX_ANGULAR_SPEED = 1.10 +DIRECT_ROUTE_PULSE_S = 0.35 +DIRECT_ROUTE_MAX_SPEED = 0.35 +DIRECT_ROUTE_MAX_ANGULAR_SPEED = 0.65 +DIRECT_ROUTE_GAIN = 0.8 +DIRECT_ROUTE_REACH_RADIUS_M = 0.35 +DIRECT_ROUTE_WAYPOINT_TIMEOUT_S = 20.0 +ROBOT_CALL_TIMEOUT_S = 8.0 +DIMOS_MCP_CALL_TIMEOUT_S = 12.0 +DIMOS_ROUTE_CALL_TIMEOUT_S = 180.0 +WEBRTC_COMMAND_TIMEOUT_S = 2.0 +MCP_UNAVAILABLE_MARKERS = ( + "no running mcp server", + "mcp server is not running", + "connection refused", + "failed to connect", + "tool not found", +) +HARD_STOP_REPEATS = 6 +HARD_STOP_INTERVAL_S = 0.05 +ROBOT_POSE_HISTORY_LIMIT = 240 +MOTION_PROFILES: dict[str, tuple[float, float, float]] = { + "nudge": (0.35, 1.0, 1.0), + "step": (1.00, 2.3, 2.0), + "walk": (2.00, 4.0, 3.0), +} +DEFAULT_MOTION_PROFILE = "nudge" +ROBOT_JOG_COMMANDS: dict[str, tuple[float, float, float]] = { + "forward": (0.15, 0.0, 0.0), + "backward": (-0.15, 0.0, 0.0), + "left": (0.0, 0.15, 0.0), + "right": (0.0, -0.15, 0.0), + "yaw_left": (0.0, 0.0, 0.35), + "yaw_right": (0.0, 0.0, -0.35), + "hard_stop": (0.0, 0.0, 0.0), + "stop": (0.0, 0.0, 0.0), +} +HARD_STOP_COMMANDS = {"hard_stop", "stop"} +ROBOT_POSTURE_COMMANDS = {"wake", "balance", "sleep"} +ROBOT_CONTROL_TOKEN_HEADER = "X-DogOps-Control-Token" +DEFAULT_ROBOT_IP = ( + os.environ.get("DOGOPS_ROBOT_IP") + or os.environ.get("GO2_IP") + or os.environ.get("ROBOT_IP") + or "192.168.12.1" +) +_ROBOT_SESSIONS: dict[str, _RobotMotionSession] = {} +_ROBOT_SESSIONS_LOCK = threading.Lock() +_AUTHORING_LOCK = threading.Lock() +_QR_EVENTS_LOCK = threading.Lock() +_LIVE_MAP_ADAPTER = DogOpsLiveMapAdapter() +_LIVE_CAMERA_ADAPTER = DogOpsLiveCameraAdapter() + + +class DogOpsDashboardServer(ThreadingHTTPServer): + def __init__( + self, + server_address: tuple[str, int], + handler_class: type[BaseHTTPRequestHandler], + *, + live_map_adapter: DogOpsLiveMapAdapter, + live_camera_adapter: DogOpsLiveCameraAdapter | None = None, + ) -> None: + self.live_map_adapter = live_map_adapter + self.live_camera_adapter = live_camera_adapter + super().__init__(server_address, handler_class) + + def server_close(self) -> None: + for adapter_name in ("live_map_adapter", "live_camera_adapter"): + stop = getattr(getattr(self, adapter_name, None), "stop", None) + if stop is not None: + stop() + with _ROBOT_SESSIONS_LOCK: + sessions = list(_ROBOT_SESSIONS.values()) + _ROBOT_SESSIONS.clear() + for session in sessions: + session.close() + super().server_close() + + +def make_dashboard_server(run_dir: str | Path, host: str, port: int) -> ThreadingHTTPServer: + root = Path(run_dir) + robot_control_token = os.environ.get("DOGOPS_DASHBOARD_TOKEN") or secrets.token_urlsafe(32) + write_dashboard_html(root, robot_control_token=robot_control_token) + token = robot_control_token + + class Handler(DogOpsDashboardHandler): + run_dir = root + robot_control_token = token + robot_ip = DEFAULT_ROBOT_IP + + return DogOpsDashboardServer( + (host, port), + Handler, + live_map_adapter=_LIVE_MAP_ADAPTER, + live_camera_adapter=_LIVE_CAMERA_ADAPTER, + ) + + +def serve_dashboard(run_dir: str | Path, host: str = "127.0.0.1", port: int = 8765) -> None: + server = make_dashboard_server(run_dir, host, port) + address = f"http://{host}:{server.server_address[1]}" + print(f"DogOps dashboard serving {Path(run_dir)} at {address}") + try: + server.serve_forever() + finally: + server.server_close() + + +class DogOpsDashboardModule(Module): + def __init__( + self, + *, + run_dir: str | Path = ".dogops/runs/latest", + host: str = "127.0.0.1", + port: int = 8765, + **_: object, + ) -> None: + if _: + super().__init__(**_) + self.run_dir = Path(run_dir) + self.host = host + self.port = port + + def write_dashboard(self) -> str: + return str(write_dashboard_html(self.run_dir)) + + def serve(self) -> None: + serve_dashboard(self.run_dir, self.host, self.port) + + def status(self) -> dict[str, object]: + return { + "run_dir": str(self.run_dir), + "dashboard_html": str(self.run_dir / "dashboard.html"), + "host": self.host, + "port": self.port, + "exists": (self.run_dir / "dashboard.html").exists(), + } + + +class DogOpsDashboardHandler(BaseHTTPRequestHandler): + run_dir: Path + robot_control_token: str + robot_ip: str + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + if path in {"/", "/dashboard.html"}: + self._send_file(self.run_dir / "dashboard.html", "text/html; charset=utf-8") + elif path == "/api/state": + self._send_file(self.run_dir / "state.json", "application/json") + elif path == "/api/report": + self._send_file(self.run_dir / "report.json", "application/json") + elif path == "/api/nav": + report = self._read_json(self.run_dir / "report.json") + self._send_json(report.get("nav_summary") or {}) + elif path == "/api/camera/status": + if self._authorize_local_read(): + self._send_json(_LIVE_CAMERA_ADAPTER.status()) + elif path == "/api/camera/frame.jpg": + if self._authorize_local_read(): + self._send_camera_frame() + elif path == "/api/map": + state = self._read_json(self.run_dir / "state.json") + report = self._read_json(self.run_dir / "report.json") + authoring = self._load_authoring(state) + self._send_json( + build_map_data( + state, + report, + live_overlay=_LIVE_MAP_ADAPTER.snapshot(), + heatmap_snapshot=latest_heatmap_snapshot(self.run_dir), + authoring=authoring.model_dump(mode="json"), + qr_events=load_qr_events(self.run_dir), + ) + ) + elif path == "/api/map/authoring": + state = self._read_json(self.run_dir / "state.json") + self._send_json(self._load_authoring(state).model_dump(mode="json")) + elif path == "/api/qr/events": + self._send_qr_events() + elif path == "/api/qr/events/latest": + self._send_latest_qr_events(parse_qs(parsed.query)) + elif path.startswith("/api/qr/events/"): + self._send_qr_event(unquote(path.split("/")[-1])) + elif path == "/api/map/routes/status": + if self._authorize_map_authoring_write(): + self._route_execution_status() + elif path == "/api/route-runs": + if self._authorize_map_authoring_write(): + self._route_runs_list() + elif path == "/api/route-runs/current": + if self._authorize_map_authoring_write(): + self._route_runs_current() + elif path == "/api/route-runs/images": + if self._authorize_map_authoring_write(): + self._route_run_images() + elif path.startswith("/api/route-runs/"): + if self._authorize_map_authoring_write(): + self._route_runs_detail(path) + elif path == "/api/robot/pose": + if self._authorize_local_read(): + self._send_json(_robot_pose_snapshot(self.robot_ip)) + elif path == "/api/route": + state = self._read_json(self.run_dir / "state.json") + report = self._read_json(self.run_dir / "report.json") + authoring = self._load_authoring(state) + self._send_json(build_route_data(state, report, authoring=authoring.model_dump(mode="json"))) + elif path == "/api/poi": + state = self._read_json(self.run_dir / "state.json") + report = self._read_json(self.run_dir / "report.json") + self._send_json(build_poi_data(state, report)) + else: + self._send_json({"error": "not_found", "path": path}, HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + path = urlparse(self.path).path + if path.startswith("/api/work_orders/") and path.endswith("/ready_to_verify"): + work_order_id = path.split("/")[3] + self._mark_work_order_ready(work_order_id) + elif path == "/api/operator/event": + self._record_operator_event() + elif path == "/api/qr/events": + if self._authorize_map_authoring_write(): + self._record_qr_event() + elif path.startswith("/api/qr/events/") and path.endswith("/promote_to_package"): + if self._authorize_map_authoring_write(): + self._promote_qr_event_to_package(_qr_event_id_from_path(path)) + elif path.startswith("/api/qr/events/") and path.endswith("/promote_to_label"): + if self._authorize_map_authoring_write(): + self._promote_qr_event_to_label(_qr_event_id_from_path(path)) + elif path.startswith("/api/qr/events/") and path.endswith("/bind_location_node"): + if self._authorize_map_authoring_write(): + self._bind_qr_location_node(_qr_event_id_from_path(path)) + elif path == "/api/robot/jog": + if self._authorize_robot_control(): + self._robot_jog() + elif path == "/api/robot/posture": + if self._authorize_robot_control(): + self._robot_posture() + elif path == "/api/robot/go_to": + if self._authorize_robot_control(): + self._robot_go_to() + elif path == "/api/robot/map_start": + if self._authorize_robot_control(): + self._robot_map_start() + elif path == "/api/robot/map_origin": + if self._authorize_robot_control(): + self._robot_map_origin() + elif path == "/api/robot/scan_zone": + if self._authorize_robot_control(): + self._robot_scan_zone() + elif path == "/api/map/heatmap/gather": + if self._authorize_robot_control(): + self._gather_heatmap() + elif path == "/api/map/entities": + if self._authorize_map_authoring_write(): + self._upsert_map_entity() + elif path == "/api/map/no_go_shapes": + if self._authorize_map_authoring_write(): + self._upsert_no_go_shape() + elif path == "/api/map/routes": + if self._authorize_map_authoring_write(): + self._upsert_map_route() + elif path == "/api/map/routes/follow": + if self._authorize_map_authoring_write(): + self._follow_map_route() + elif path == "/api/map/routes/stop": + if self._authorize_map_authoring_write(): + self._stop_map_route() + elif path.startswith("/api/map/incidents/") and path.endswith("/location"): + if self._authorize_map_authoring_write(): + parts = path.split("/") + incident_id = parts[4] if len(parts) >= 6 else "" + self._upsert_incident_location(incident_id) + elif path == "/api/map/tag_bindings": + if self._authorize_map_authoring_write(): + self._upsert_tag_binding() + elif path == "/api/map/export": + if self._authorize_map_authoring_write(): + self._export_map_authoring() + elif path == "/api/map/no_go_shapes/publish": + if self._authorize_map_authoring_write(): + self._publish_no_go_shapes() + elif path.startswith("/api/map/entities/") and path.endswith("/from_observation"): + if self._authorize_map_authoring_write(): + parts = path.split("/") + entity_id = parts[4] if len(parts) >= 6 else "" + self._place_entity_from_observation(entity_id) + elif path.startswith("/api/map/routes/") and path.endswith("/select"): + if self._authorize_map_authoring_write(): + parts = path.split("/") + route_id = parts[4] if len(parts) >= 6 else "" + self._select_map_route(route_id) + else: + self._send_json({"error": "not_found", "path": path}, HTTPStatus.NOT_FOUND) + + def do_PUT(self) -> None: + path = urlparse(self.path).path + if path == "/api/map/authoring": + if self._authorize_map_authoring_write(): + self._replace_map_authoring() + elif path.startswith("/api/map/entities/"): + if self._authorize_map_authoring_write(): + self._upsert_map_entity(path.split("/")[-1]) + elif path.startswith("/api/map/no_go_shapes/"): + if self._authorize_map_authoring_write(): + self._upsert_no_go_shape(path.split("/")[-1]) + elif path.startswith("/api/map/routes/"): + if self._authorize_map_authoring_write(): + self._upsert_map_route(path.split("/")[-1]) + else: + self._send_json({"error": "not_found", "path": path}, HTTPStatus.NOT_FOUND) + + def do_DELETE(self) -> None: + path = urlparse(self.path).path + if path.startswith("/api/map/entities/"): + if self._authorize_map_authoring_write(): + self._delete_map_entity(path.split("/")[-1]) + elif path.startswith("/api/map/no_go_shapes/"): + if self._authorize_map_authoring_write(): + self._delete_no_go_shape(path.split("/")[-1]) + elif path.startswith("/api/map/routes/"): + if self._authorize_map_authoring_write(): + self._delete_map_route(path.split("/")[-1]) + elif path.startswith("/api/map/tag_bindings/"): + if self._authorize_map_authoring_write(): + self._delete_tag_binding(path.split("/")[-1]) + else: + self._send_json({"error": "not_found", "path": path}, HTTPStatus.NOT_FOUND) + + def log_message(self, format: str, *args: object) -> None: + return + + def _load_authoring(self, state: dict[str, Any] | None = None) -> MapAuthoringState: + if state is None: + state = self._read_json(self.run_dir / "state.json") + site_id = str((state.get("site") or {}).get("site_id") or "") + return load_map_authoring(self.run_dir, site_id=site_id) + + def _send_authoring(self, authoring: MapAuthoringState) -> None: + state = self._read_json(self.run_dir / "state.json") + report = self._read_json(self.run_dir / "report.json") + payload = authoring.model_dump(mode="json") + self._send_json( + { + "ok": True, + "authoring": payload, + "map": build_map_data( + state, + report, + live_overlay=_LIVE_MAP_ADAPTER.snapshot(), + heatmap_snapshot=latest_heatmap_snapshot(self.run_dir), + authoring=payload, + qr_events=load_qr_events(self.run_dir), + ), + } + ) + + def _handle_authoring_error(self, exc: Exception) -> None: + message = ( + validation_error_message(exc) + if isinstance(exc, (ValidationError, ValueError)) + else str(exc) + ) + self._send_json( + {"ok": False, "error": "invalid_map_authoring", "message": message}, + HTTPStatus.BAD_REQUEST, + ) + + def _persist_authoring_mutation(self, mutate: Any) -> MapAuthoringState: + with _AUTHORING_LOCK: + authoring = mutate(self._load_authoring()) + self._save_authoring(authoring) + return authoring + + def _save_authoring(self, authoring: MapAuthoringState) -> None: + save_map_authoring(self.run_dir, authoring) + write_dashboard_html(self.run_dir, robot_control_token=self.robot_control_token) + + def _replace_map_authoring(self) -> None: + state = self._read_json(self.run_dir / "state.json") + payload = self._read_body_json() + payload.setdefault("site_id", str((state.get("site") or {}).get("site_id") or "")) + try: + authoring = MapAuthoringState.model_validate(payload) + with _AUTHORING_LOCK: + self._save_authoring(authoring) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _upsert_map_entity(self, entity_id: str | None = None) -> None: + payload = self._read_body_json() + if entity_id: + payload["id"] = entity_id + try: + entity = EditableMapEntity.model_validate(payload) + authoring = self._persist_authoring_mutation( + lambda existing: replace_entity(existing, entity) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _place_entity_from_observation(self, entity_id: str) -> None: + payload = self._read_body_json() + state = self._read_json(self.run_dir / "state.json") + try: + observation = _resolve_observation(state, payload) + pose = _observation_pose(state, observation) + existing = _authored_or_site_entity(state, self._load_authoring(), entity_id) + if existing is None: + existing = { + "id": entity_id, + "kind": str(payload.get("kind") or "checkpoint"), + "label": entity_id, + } + existing["pose"] = { + "x": pose[0], + "y": pose[1], + "theta_deg": pose[2], + "source": "observation", + } + if observation.get("tag_id") is not None: + existing["tag_id"] = observation.get("tag_id") + entity = EditableMapEntity.model_validate(existing) + authoring = self._persist_authoring_mutation( + lambda current: replace_entity(current, entity) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _delete_map_entity(self, entity_id: str) -> None: + authoring = self._persist_authoring_mutation( + lambda existing: delete_entity(existing, entity_id) + ) + self._send_authoring(authoring) + + def _upsert_no_go_shape(self, shape_id: str | None = None) -> None: + payload = self._read_body_json() + if shape_id: + payload["id"] = shape_id + payload.setdefault("dimos_constraint_status", "not_supported") + try: + shape = EditableNoGoShape.model_validate(payload) + authoring = self._persist_authoring_mutation( + lambda existing: replace_no_go_shape(existing, shape) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _delete_no_go_shape(self, shape_id: str) -> None: + authoring = self._persist_authoring_mutation( + lambda existing: delete_no_go_shape(existing, shape_id) + ) + self._send_authoring(authoring) + + def _upsert_map_route(self, route_id: str | None = None) -> None: + payload = self._read_body_json() + if route_id: + payload["id"] = route_id + try: + route = EditableRoute.model_validate(payload) + authoring = self._persist_authoring_mutation( + lambda existing: replace_route(existing, route) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _delete_map_route(self, route_id: str) -> None: + authoring = self._persist_authoring_mutation( + lambda existing: delete_route(existing, route_id) + ) + self._send_authoring(authoring) + + def _select_map_route(self, route_id: str) -> None: + try: + authoring = self._persist_authoring_mutation( + lambda existing: select_route(existing, route_id) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _gather_heatmap(self) -> None: + payload = self._read_body_json() + area_id = str(payload.get("area_id") or "").strip() + try: + duration_s = float(payload.get("duration_s") or 0.0) + except (TypeError, ValueError): + duration_s = 0.0 + result = gather_heatmap_run( + self.run_dir, + live_snapshot=_LIVE_MAP_ADAPTER.snapshot(), + live_snapshot_reader=_LIVE_MAP_ADAPTER.snapshot, + area_id=area_id, + duration_s=max(0.0, duration_s), + ) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.CONFLICT + self._send_json(result, status) + + def _follow_map_route(self) -> None: + payload = self._read_body_json() + route_id = payload.get("route_id") + route_id_arg = str(route_id).strip() if route_id is not None else None + dry_run = bool(payload.get("dry_run", False)) + use_planner = bool(payload.get("use_planner", False)) + try: + result = _run_robot_call( + lambda: _run_robot_follow_route( + route_id_arg, + dry_run, + use_planner=use_planner, + robot_ip=self.robot_ip, + run_dir=self.run_dir, + ), + timeout_s=DIMOS_ROUTE_CALL_TIMEOUT_S, + ) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + **self._route_execution_payload(), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + { + "ok": False, + "error": "follow_route_timeout", + "message": str(exc), + **self._route_execution_payload(), + }, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + if _is_mcp_unavailable_error(exc): + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + **self._route_execution_payload(), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + self._send_json( + { + "ok": False, + "error": "follow_route_failed", + "message": str(exc), + **self._route_execution_payload(), + }, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + route_execution = _mcp_route_execution(result) + self._send_json( + { + "ok": True, + "command": "follow_route", + "route_mode": ( + "planner" + if use_planner and not dry_run + else ("dry_run" if dry_run else "direct") + ), + **(result or {}), + **self._route_execution_payload(route_execution=route_execution), + } + ) + + def _stop_map_route(self) -> None: + request_route_stop(self.run_dir) + hard_stop: dict[str, Any] = {"attempted": True, "ok": False} + try: + hard_stop_result = _run_robot_call(lambda: _run_route_hard_stop(self.robot_ip)) + except Exception as exc: + hard_stop["error"] = str(exc) + else: + hard_stop.update({"ok": True, **(hard_stop_result or {})}) + try: + result = _run_robot_call(_run_robot_stop_route) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + "hard_stop": hard_stop, + **self._route_execution_payload(), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + { + "ok": False, + "error": "stop_route_timeout", + "message": str(exc), + "hard_stop": hard_stop, + **self._route_execution_payload(), + }, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + if _is_mcp_unavailable_error(exc): + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + "hard_stop": hard_stop, + **self._route_execution_payload(), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + self._send_json( + { + "ok": False, + "error": "stop_route_failed", + "message": str(exc), + "hard_stop": hard_stop, + **self._route_execution_payload(), + }, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + route_execution = _mcp_route_execution(result) + self._send_json( + { + "ok": True, + "command": "stop_route", + "hard_stop": hard_stop, + **(result or {}), + **self._route_execution_payload(route_execution=route_execution), + } + ) + + def _route_execution_status(self) -> None: + self._send_json({"ok": True, **self._route_execution_payload()}) + + def _route_runs_list(self) -> None: + store = RouteRunStore(self.run_dir) + self._send_json({"ok": True, "route_runs": store.list_route_runs()}) + + def _route_runs_current(self) -> None: + store = RouteRunStore(self.run_dir) + current = store.current_route_run() + route_events = store.route_run_events(current["route_run_id"]) if current else [] + self._send_json( + { + "ok": True, + "route_run": current, + "events": route_events, + "timeline": self._unified_timeline(route_events, current) if current else [], + "evidence": store.route_run_evidence(current["route_run_id"]) if current else [], + } + ) + + def _route_run_images(self) -> None: + store = RouteRunStore(self.run_dir) + images: list[dict[str, Any]] = [] + for route_run in store.list_route_runs(limit=12): + route_run_id = str(route_run.get("route_run_id") or "") + if not route_run_id: + continue + for evidence in store.route_run_evidence(route_run_id): + if evidence.get("kind") != "image" or not evidence.get("path"): + continue + evidence_id = str(evidence.get("evidence_id") or "") + images.append( + { + "evidence_id": evidence_id, + "route_run_id": route_run_id, + "dogops_run_id": route_run.get("dogops_run_id"), + "route_id": route_run.get("route_id"), + "created_at": evidence.get("created_at"), + "mime_type": evidence.get("mime_type"), + "metadata": evidence.get("metadata") or {}, + "url": f"/api/route-runs/{route_run_id}/evidence/{evidence_id}/file", + } + ) + images.sort(key=lambda item: float(item.get("created_at") or 0.0), reverse=True) + self._send_json({"ok": True, "images": images[:12]}) + + def _route_runs_detail(self, path: str) -> None: + parts = [part for part in path.split("/") if part] + route_run_id = parts[2] if len(parts) >= 3 else "" + if not route_run_id: + self._send_json({"ok": False, "error": "missing_route_run_id"}, HTTPStatus.BAD_REQUEST) + return + store = RouteRunStore(self.run_dir) + route_run = store.route_run_detail(route_run_id) + if not route_run: + self._send_json({"ok": False, "error": "route_run_not_found"}, HTTPStatus.NOT_FOUND) + return + if len(parts) == 4 and parts[3] == "events": + self._send_json({"ok": True, "events": store.route_run_events(route_run_id)}) + return + if len(parts) == 4 and parts[3] == "evidence": + self._send_json({"ok": True, "evidence": store.route_run_evidence(route_run_id)}) + return + if len(parts) == 6 and parts[3] == "evidence" and parts[5] == "file": + self._send_route_run_evidence_file(store, route_run, parts[4]) + return + route_events = store.route_run_events(route_run_id) + evidence = store.route_run_evidence(route_run_id) + self._send_json( + { + "ok": True, + "route_run": route_run, + "events": route_events, + "timeline": self._unified_timeline(route_events, route_run), + "evidence": evidence, + "map": self._route_run_map(route_run, evidence), + } + ) + + def _send_route_run_evidence_file( + self, + store: RouteRunStore, + route_run: dict[str, Any], + evidence_id: str, + ) -> None: + evidence = next( + ( + item + for item in store.route_run_evidence(str(route_run["route_run_id"])) + if item.get("evidence_id") == evidence_id + ), + None, + ) + if evidence is None or evidence.get("kind") != "image" or not evidence.get("path"): + self._send_json({"ok": False, "error": "evidence_image_not_found"}, HTTPStatus.NOT_FOUND) + return + mime_type = str(evidence.get("mime_type") or "") + if mime_type not in {"image/jpeg", "image/png", "image/webp"}: + self._send_json({"ok": False, "error": "unsupported_evidence_image_type"}, HTTPStatus.UNSUPPORTED_MEDIA_TYPE) + return + run_root = Path(str(route_run.get("run_dir") or self.run_dir)).resolve() + evidence_path = Path(str(evidence["path"])) + resolved = evidence_path.resolve() + try: + resolved.relative_to(run_root) + except ValueError: + self._send_json({"ok": False, "error": "evidence_path_outside_run"}, HTTPStatus.FORBIDDEN) + return + self._send_file(resolved, mime_type) + + def _route_run_map( + self, + route_run: dict[str, Any], + evidence: list[dict[str, Any]], + ) -> dict[str, Any] | None: + timeline_run_dir = Path(str(route_run.get("run_dir") or self.run_dir)) + state_path = timeline_run_dir / "state.json" + report_path = timeline_run_dir / "report.json" + if not state_path.exists() or not report_path.exists(): + return None + state = self._read_json(state_path) + report = self._read_json(report_path) + authoring = load_map_authoring( + timeline_run_dir, + site_id=str((state.get("site") or {}).get("site_id") or ""), + ).model_dump(mode="json") + route_snapshot = route_run.get("selected_route_snapshot") + if isinstance(route_snapshot, dict) and route_snapshot.get("waypoints"): + routes = [ + route + for route in authoring.get("routes") or [] + if isinstance(route, dict) and route.get("id") != route_snapshot.get("id") + ] + routes.append(route_snapshot) + authoring["routes"] = routes + authoring["selected_route_id"] = route_snapshot.get("id") + heatmap_snapshot = heatmap_snapshot_for_route_run( + timeline_run_dir, + str(route_run.get("route_run_id") or ""), + evidence=evidence, + ) + return build_map_data( + state, + report, + heatmap_snapshot=heatmap_snapshot, + authoring=authoring, + qr_events=load_qr_events(timeline_run_dir), + ) + + def _unified_timeline( + self, + route_events: list[dict[str, Any]], + route_run: dict[str, Any], + ) -> list[dict[str, Any]]: + timeline_run_dir = Path(str(route_run.get("run_dir") or self.run_dir)) + state = self._read_optional_json(timeline_run_dir / "state.json") + report = self._read_optional_json(timeline_run_dir / "report.json") + route_run_id = str(route_run.get("route_run_id") or "") + dogops_run_id = str(route_run.get("dogops_run_id") or state.get("run", {}).get("id") or timeline_run_dir.name) + rows = [ + { + "event_id": f"TL-{event.get('event_id') or event.get('sequence')}", + "route_run_id": event.get("route_run_id") or route_run_id, + "ts": event.get("ts") or 0, + "sequence": event.get("sequence"), + "kind": event.get("kind") or "route", + "state": event.get("state") or "", + "target_id": event.get("target_id") or event.get("waypoint_id") or event.get("action_id"), + "note": event.get("note") or "", + } + for event in route_events + ] + for observation in state.get("observations") or []: + rows.append( + { + "event_id": f"OBS-{observation.get('id')}", + "route_run_id": route_run_id, + "ts": observation.get("ts") or 0, + "sequence": "", + "kind": "observation", + "state": "recorded", + "target_id": observation.get("entity_id") or observation.get("zone_id"), + "note": f"observation {observation.get('id')}", + } + ) + for incident in report.get("incidents") or []: + rows.append( + { + "event_id": f"INC-{incident.get('id')}", + "route_run_id": route_run_id, + "ts": incident.get("ts_open") or 0, + "sequence": "", + "kind": "incident", + "state": incident.get("state") or "", + "target_id": incident.get("entity_id"), + "note": incident.get("title") or incident.get("id") or "", + } + ) + incidents_by_id = {item.get("id"): item for item in report.get("incidents") or []} + for work_order in report.get("work_orders") or []: + incident = incidents_by_id.get(work_order.get("incident_id")) or {} + rows.append( + { + "event_id": f"WO-{work_order.get('id')}", + "route_run_id": route_run_id, + "ts": incident.get("ts_closed") or incident.get("ts_open") or 0, + "sequence": "", + "kind": "work_order", + "state": work_order.get("state") or "", + "target_id": work_order.get("incident_id"), + "note": work_order.get("requested_action") or work_order.get("id") or "", + } + ) + for checkpoint in report.get("checkpoint_verifications") or []: + rows.append( + { + "event_id": f"VER-{checkpoint.get('target_id')}", + "route_run_id": route_run_id, + "ts": state.get("run", {}).get("ended_at") or state.get("run", {}).get("started_at") or 0, + "sequence": "", + "kind": "verification", + "state": "verified" if checkpoint.get("verified") else "missing", + "target_id": checkpoint.get("target_id"), + "note": f"expected tag {checkpoint.get('expected_tag_id')}", + } + ) + rows.sort(key=lambda item: (float(item.get("ts") or 0), str(item.get("kind") or ""))) + for index, row in enumerate(rows, 1): + row["sequence"] = row.get("sequence") or index + store = RouteRunStore(timeline_run_dir) + store.replace_timeline_events(dogops_run_id, rows, route_run_id=route_run_id) + return store.timeline_events( + dogops_run_id=dogops_run_id, + route_run_id=route_run_id, + ) + + def _route_execution_payload( + self, + *, + route_execution: dict[str, Any] | None = None, + ) -> dict[str, Any]: + state = self._read_json(self.run_dir / "state.json") + authoring = self._load_authoring(state) + return { + "route_execution": route_execution + or load_route_execution(self.run_dir).model_dump(mode="json"), + "authoring": authoring.model_dump(mode="json"), + "route_run": RouteRunStore(self.run_dir).current_route_run(), + "live": _LIVE_MAP_ADAPTER.snapshot(), + } + + def _upsert_incident_location(self, incident_id: str) -> None: + payload = self._read_body_json() + payload["incident_id"] = incident_id or payload.get("incident_id") + try: + location = EditableIncidentLocation.model_validate(payload) + authoring = self._persist_authoring_mutation( + lambda existing: replace_incident_location(existing, location) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _upsert_tag_binding(self) -> None: + payload = self._read_body_json() + try: + binding = EditableTagBinding.model_validate(payload) + + def add_binding(existing: MapAuthoringState) -> MapAuthoringState: + if any(item.tag_id == binding.tag_id for item in existing.tag_bindings): + raise ValueError(f"duplicate tag id: {binding.tag_id}") + return replace_tag_binding(existing, binding) + + authoring = self._persist_authoring_mutation(add_binding) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _delete_tag_binding(self, tag_id: str) -> None: + try: + parsed_tag_id = int(tag_id) + except ValueError: + self._send_json( + {"ok": False, "error": "invalid_tag_id", "tag_id": tag_id}, + HTTPStatus.BAD_REQUEST, + ) + return + authoring = self._persist_authoring_mutation( + lambda existing: delete_tag_binding(existing, parsed_tag_id) + ) + self._send_authoring(authoring) + + def _export_map_authoring(self) -> None: + authoring = self._load_authoring() + paths = export_authoring_yaml(self.run_dir, authoring) + self._send_json({"ok": True, "exports": paths}) + + def _publish_no_go_shapes(self) -> None: + try: + with _AUTHORING_LOCK: + authoring = publish_no_go_constraints(self._load_authoring()) + self._save_authoring(authoring) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + self._send_authoring(authoring) + + def _mark_work_order_ready(self, work_order_id: str) -> None: + store = DogOpsStore.load_existing(self.run_dir) + state = store.state + assert state is not None + for work_order in state.work_orders: + if work_order.id == work_order_id: + work_order.state = "ready_to_verify" + store.update_work_order(work_order) + store.write_state(state.run.id) + store.write_report(state.run.id) + write_dashboard_html(self.run_dir) + self._send_json({"ok": True, "work_order_id": work_order_id, "state": "ready_to_verify"}) + return + self._send_json( + {"ok": False, "error": "unknown_work_order", "work_order_id": work_order_id}, + HTTPStatus.NOT_FOUND, + ) + + def _record_operator_event(self) -> None: + payload = self._read_body_json() + events_path = self.run_dir / "operator_events.jsonl" + with events_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + self._send_json({"ok": True, "path": str(events_path)}) + + def _send_qr_events(self) -> None: + events = load_qr_events(self.run_dir) + self._send_json( + { + "ok": True, + "events": events, + "count": len(events), + "path": str(qr_events_path(self.run_dir)), + } + ) + + def _send_latest_qr_events(self, query: dict[str, list[str]]) -> None: + events = get_latest_qr_events(self.run_dir, limit=_limit_from_query(query)) + self._send_json({"ok": True, "events": events, "count": len(events)}) + + def _send_qr_event(self, event_id: str) -> None: + event = get_qr_event(self.run_dir, event_id) + if event is None: + self._send_json( + {"ok": False, "error": "unknown_qr_event", "event_id": event_id}, + HTTPStatus.NOT_FOUND, + ) + return + self._send_json({"ok": True, "event": event}) + + def _record_qr_event(self) -> None: + payload = self._read_body_json() + try: + with _QR_EVENTS_LOCK: + event = append_qr_event(self.run_dir, payload) + write_dashboard_html( + self.run_dir, + robot_control_token=self.robot_control_token, + ) + except (ValueError, json.JSONDecodeError) as exc: + self._send_json( + {"ok": False, "error": "invalid_qr_event", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + return + self._send_json( + { + "ok": True, + "event": event, + "path": str(qr_events_path(self.run_dir)), + }, + HTTPStatus.CREATED, + ) + + def _promote_qr_event_to_package(self, event_id: str) -> None: + self._promote_qr_event_to_authoring(event_id, entity_kind="package") + + def _promote_qr_event_to_label(self, event_id: str) -> None: + self._promote_qr_event_to_authoring(event_id, entity_kind="checkpoint") + + def _bind_qr_location_node(self, event_id: str) -> None: + self._promote_qr_event_to_authoring(event_id, entity_kind="checkpoint") + + def _promote_qr_event_to_authoring(self, event_id: str, *, entity_kind: str) -> None: + event = get_qr_event(self.run_dir, event_id) + if event is None: + self._send_json( + {"ok": False, "error": "unknown_qr_event", "event_id": event_id}, + HTTPStatus.NOT_FOUND, + ) + return + + payload = event.get("qr_payload") if isinstance(event.get("qr_payload"), dict) else {} + if entity_kind == "package": + entity_id = str(payload.get("cargo_id") or event_id) + label = entity_id + zone_id = str(payload.get("location_node_id") or payload.get("zone") or "") + else: + entity_id = str(payload.get("location_node_id") or event_id) + label = entity_id + zone_id = str(payload.get("zone") or "") + + try: + position = self._qr_authoring_position(event) + if position is None: + raise ValueError("QR event has no map position to promote") + entity = EditableMapEntity.model_validate( + { + "id": entity_id, + "kind": entity_kind, + "label": label, + "pose": { + "x": position["x"], + "y": position["y"], + "theta_deg": None, + "source": "qr_cargo_event", + }, + "zone_id": zone_id or None, + "source_id": event_id, + } + ) + authoring = self._persist_authoring_mutation( + lambda existing: replace_entity(existing, entity) + ) + except (ValidationError, ValueError) as exc: + self._handle_authoring_error(exc) + return + + self._send_authoring(authoring) + + def _qr_authoring_position(self, event: dict[str, Any]) -> dict[str, Any] | None: + state = self._read_json(self.run_dir / "state.json") + report = self._read_json(self.run_dir / "report.json") + authoring = self._load_authoring(state).model_dump(mode="json") + map_data = build_map_data( + state, + report, + live_overlay=_LIVE_MAP_ADAPTER.snapshot(), + authoring=authoring, + qr_events=[event], + ) + overlays = map_data.get("qr_cargo_events") or [] + if not overlays: + return None + overlay = overlays[0] + position = overlay.get("map_position") + return position if isinstance(position, dict) else None + + def _robot_jog(self) -> None: + payload = self._read_body_json() + command = str(payload.get("command", "stop")) + if command not in ROBOT_JOG_COMMANDS: + self._send_json( + {"ok": False, "error": "unknown_robot_command", "command": command}, + HTTPStatus.BAD_REQUEST, + ) + return + + robot_ip = self.robot_ip + + try: + linear_x, linear_y, angular_z, duration_s, profile = _resolve_motion_request( + command, payload + ) + if command in HARD_STOP_COMMANDS: + motion_result = _run_robot_call(lambda: _publish_robot_hard_stop(robot_ip)) + else: + motion_result = _run_robot_call( + lambda: _publish_robot_jog(linear_x, linear_y, angular_z, duration_s, robot_ip) + ) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_motion_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "robot_command_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + self._send_json( + {"ok": False, "error": "robot_command_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json( + { + "ok": True, + "command": command, + "duration_s": 0.0 if command in HARD_STOP_COMMANDS else duration_s, + "linear_x": linear_x, + "linear_y": linear_y, + "angular_z": angular_z, + "profile": profile, + **(motion_result or {}), + } + ) + + def _robot_posture(self) -> None: + payload = self._read_body_json() + command = str(payload.get("command", "")) + if command not in ROBOT_POSTURE_COMMANDS: + self._send_json( + {"ok": False, "error": "unknown_posture_command", "command": command}, + HTTPStatus.BAD_REQUEST, + ) + return + + robot_ip = self.robot_ip + try: + ok = _run_robot_call(lambda: _run_robot_posture(command, robot_ip)) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_motion_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "posture_command_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + self._send_json( + {"ok": False, "error": "posture_command_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json({"ok": bool(ok), "command": command}) + + def _robot_go_to(self) -> None: + payload = self._read_body_json() + try: + x, y = _go_to_target(payload) + except (ValidationError, ValueError) as exc: + self._send_json( + {"ok": False, "error": "invalid_go_to_target", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + return + + source = str(payload.get("source") or "dashboard") + try: + result = _run_robot_call(lambda: _run_robot_go_to(x, y)) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "go_to_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + self._send_json( + {"ok": False, "error": "go_to_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json( + { + "ok": True, + "command": "go_to", + "x": x, + "y": y, + "source": source, + **(result or {}), + } + ) + + def _robot_scan_zone(self) -> None: + payload = self._read_body_json() + zone_id = str(payload.get("zone_id") or "").strip() + if not zone_id: + self._send_json( + {"ok": False, "error": "missing_zone_id"}, + HTTPStatus.BAD_REQUEST, + ) + return + + try: + result = _run_robot_call(lambda: _run_robot_scan_zone(zone_id)) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "scan_zone_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + if _is_mcp_unavailable_error(exc): + self._send_json( + { + "ok": False, + "error": "dimos_mcp_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + self._send_json( + {"ok": False, "error": "scan_zone_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json({"ok": True, "command": "scan_zone", "zone_id": zone_id, **(result or {})}) + + def _robot_map_start(self) -> None: + robot_ip = self.robot_ip + try: + result = _run_robot_call(lambda: _start_robot_map(robot_ip)) + except ModuleNotFoundError as exc: + self._send_json( + { + "ok": False, + "error": "dimos_motion_unavailable", + "message": str(exc), + }, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "map_start_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + self._send_json( + {"ok": False, "error": "map_start_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json({"ok": True, "command": "map_start", **result}) + + def _robot_map_origin(self) -> None: + robot_ip = self.robot_ip + try: + result = _run_robot_call(lambda: _reset_robot_map_origin(robot_ip)) + except TimeoutError as exc: + self._send_json( + {"ok": False, "error": "map_origin_timeout", "message": str(exc)}, + HTTPStatus.GATEWAY_TIMEOUT, + ) + return + except Exception as exc: + self._send_json( + {"ok": False, "error": "map_origin_failed", "message": str(exc)}, + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json({"ok": bool(result), "command": "map_origin"}) + + def _authorize_local_read(self) -> bool: + host = self.headers.get("Host", "") + if not _is_loopback_host(_host_name(host)): + self._send_json({"ok": False, "error": "local_read_only"}, HTTPStatus.FORBIDDEN) + return False + return True + + def _authorize_robot_control(self) -> bool: + host = self.headers.get("Host", "") + if not _is_loopback_host(_host_name(host)): + self._send_json({"ok": False, "error": "robot_control_local_only"}, HTTPStatus.FORBIDDEN) + return False + + origin = self.headers.get("Origin") + if origin and not _origin_matches_host(origin, host): + self._send_json({"ok": False, "error": "robot_control_bad_origin"}, HTTPStatus.FORBIDDEN) + return False + + expected = self.robot_control_token + provided = self.headers.get(ROBOT_CONTROL_TOKEN_HEADER, "") + if not secrets.compare_digest(provided, expected): + self._send_json({"ok": False, "error": "robot_control_forbidden"}, HTTPStatus.FORBIDDEN) + return False + + return True + + def _authorize_map_authoring_write(self) -> bool: + host = self.headers.get("Host", "") + if not _is_loopback_host(_host_name(host)): + self._send_json({"ok": False, "error": "map_authoring_local_only"}, HTTPStatus.FORBIDDEN) + return False + + origin = self.headers.get("Origin") + if origin and not _origin_matches_host(origin, host): + self._send_json({"ok": False, "error": "map_authoring_bad_origin"}, HTTPStatus.FORBIDDEN) + return False + + expected = self.robot_control_token + provided = self.headers.get(ROBOT_CONTROL_TOKEN_HEADER, "") + if not secrets.compare_digest(provided, expected): + self._send_json({"ok": False, "error": "map_authoring_forbidden"}, HTTPStatus.FORBIDDEN) + return False + + return True + + def _send_file(self, path: Path, content_type: str) -> None: + if not path.exists(): + self._send_json({"error": "missing_file", "path": str(path)}, HTTPStatus.NOT_FOUND) + return + payload = path.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None: + raw = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def _send_camera_frame(self) -> None: + try: + payload = _LIVE_CAMERA_ADAPTER.frame_jpeg() + except Exception as exc: + self._send_json( + {"ok": False, "error": "camera_frame_unavailable", "message": str(exc)}, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + if payload is None: + self._send_json( + {"ok": False, "error": "camera_frame_pending"}, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload) + + def _read_json(self, path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + def _read_optional_json(self, path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + return self._read_json(path) + + def _read_body_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0") or 0) + if length == 0: + return {} + raw = self.rfile.read(length) + return json.loads(raw.decode("utf-8")) + + +def _run_robot_call(fn: Any, *, timeout_s: float = ROBOT_CALL_TIMEOUT_S) -> Any: + result: dict[str, Any] = {} + + def target() -> None: + try: + result["value"] = fn() + except BaseException as exc: + result["error"] = exc + + thread = threading.Thread(target=target, daemon=True) + thread.start() + thread.join(timeout=timeout_s) + if thread.is_alive(): + raise TimeoutError(f"robot command exceeded {timeout_s:.1f}s") + if "error" in result: + raise result["error"] + return result.get("value") + + +def _resolve_observation(state: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + observations = state.get("observations") or [] + observation_id = payload.get("observation_id") + tag_id = payload.get("tag_id") + for observation in reversed(observations): + if observation_id and observation.get("id") == observation_id: + return observation + if tag_id is not None and observation.get("tag_id") == int(tag_id): + return observation + visible_tags = (observation.get("facts") or {}).get("visible_tag_ids") + if tag_id is not None and _tag_id_in_visible_tags(int(tag_id), visible_tags): + return observation + raise ValueError("matching observation not found") + + +def _observation_pose( + state: dict[str, Any], + observation: dict[str, Any], +) -> tuple[float, float, float | None]: + pose = observation.get("pose") or {} + x = _finite_or_none(pose.get("x")) + y = _finite_or_none(pose.get("y")) + theta = _finite_or_none(pose.get("theta_deg")) + if x is not None and y is not None: + return x, y, theta + zone_id = str(observation.get("zone_id") or "") + for zone in (state.get("site") or {}).get("zones") or []: + if zone.get("id") != zone_id: + continue + zone_pose = zone.get("pose_hint") or {} + x = _finite_or_none(zone_pose.get("x")) + y = _finite_or_none(zone_pose.get("y")) + theta = _finite_or_none(zone_pose.get("theta_deg")) + if x is not None and y is not None: + return x, y, theta + raise ValueError("observation has no map pose") + + +def _authored_or_site_entity( + state: dict[str, Any], + authoring: MapAuthoringState, + entity_id: str, +) -> dict[str, Any] | None: + for entity in authoring.entities: + if entity.id == entity_id: + return entity.model_dump(mode="json") + site = state.get("site") or {} + for kind, collection in ( + ("zone", site.get("zones") or []), + ("asset", site.get("assets") or []), + ("package", site.get("packages") or []), + ): + for item in collection: + if item.get("id") != entity_id: + continue + return { + "id": entity_id, + "kind": kind, + "label": item.get("display_name") or entity_id, + "tag_id": item.get("tag_id"), + "zone_id": item.get("zone_id") or item.get("expected_zone_id"), + } + return None + + +def _tag_id_in_visible_tags(tag_id: int, visible_tags: object) -> bool: + if isinstance(visible_tags, str): + return str(tag_id) in {item.strip() for item in visible_tags.split(",")} + if isinstance(visible_tags, list): + return any(str(item) == str(tag_id) for item in visible_tags) + return False + + +def _finite_or_none(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _clamp(value: float, minimum: float, maximum: float) -> float: + return max(minimum, min(maximum, value)) + + +def _live_robot_pose(snapshot: dict[str, Any]) -> dict[str, float] | None: + pose = snapshot.get("robot_pose") or snapshot.get("pose") + if not isinstance(pose, dict): + return None + x = _finite_or_none(pose.get("x")) + y = _finite_or_none(pose.get("y")) + if x is None or y is None: + return None + theta = _finite_or_none(pose.get("theta_deg")) + return {"x": x, "y": y, "theta_deg": theta or 0.0} + + +def _resolve_motion_request( + command: str, + payload: dict[str, Any], +) -> tuple[float, float, float, float, str]: + linear_x, linear_y, angular_z = ROBOT_JOG_COMMANDS[command] + requested_profile = str(payload.get("profile") or DEFAULT_MOTION_PROFILE) + profile = requested_profile if requested_profile in MOTION_PROFILES else DEFAULT_MOTION_PROFILE + profile_duration_s, linear_scale, angular_scale = MOTION_PROFILES[profile] + + try: + duration_s = float(payload.get("duration_s", profile_duration_s)) + except (TypeError, ValueError): + duration_s = profile_duration_s + duration_s = max(0.05, min(duration_s, MAX_JOG_DURATION_S)) + + linear_x = _cap(linear_x * linear_scale, MAX_LINEAR_SPEED) + linear_y = _cap(linear_y * linear_scale, MAX_LINEAR_SPEED) + angular_z = _cap(angular_z * angular_scale, MAX_ANGULAR_SPEED) + return linear_x, linear_y, angular_z, duration_s, profile + + +def _cap(value: float, limit: float) -> float: + return max(-limit, min(value, limit)) + + +def _go_to_target(payload: dict[str, Any]) -> tuple[float, float]: + try: + x = float(payload["x"]) + y = float(payload["y"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("go_to requires numeric x and y") from exc + if not math.isfinite(x) or not math.isfinite(y): + raise ValueError("go_to target must be finite") + return x, y + + +def _limit_from_query(query: dict[str, list[str]], *, default: int = 50) -> int: + raw = (query.get("limit") or [str(default)])[0] + try: + limit = int(raw) + except (TypeError, ValueError): + return default + return max(1, min(limit, 500)) + + +def _qr_event_id_from_path(path: str) -> str: + parts = path.split("/") + return unquote(parts[4]) if len(parts) >= 6 else "" + + +def _host_parts(host_header: str) -> tuple[str, int | None]: + try: + parsed = urlparse(f"//{host_header.strip()}") + return (parsed.hostname or "").lower(), parsed.port + except ValueError: + return "", None + + +def _host_name(host_header: str) -> str: + return _host_parts(host_header)[0] + + +def _is_loopback_host(hostname: str) -> bool: + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _origin_matches_host(origin: str, host_header: str) -> bool: + try: + parsed_origin = urlparse(origin) + origin_host = (parsed_origin.hostname or "").lower() + origin_port = parsed_origin.port + except ValueError: + return False + + request_host, request_port = _host_parts(host_header) + if parsed_origin.scheme not in {"http", "https"}: + return False + if not _is_loopback_host(origin_host): + return False + return origin_host == request_host and origin_port == request_port + + +def _publish_robot_jog( + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + robot_ip: str, +) -> dict[str, Any]: + return _publish_wireless_controller_pulse( + _joystick_payload(linear_x, linear_y, angular_z), + duration_s, + robot_ip, + ) + + +def _publish_robot_hard_stop(robot_ip: str) -> dict[str, Any]: + return _publish_wireless_controller_pulse({"lx": 0, "ly": 0, "rx": 0, "ry": 0}, 0.15, robot_ip) + + +def _publish_wireless_controller_pulse( + payload: dict[str, float | int], + duration_s: float, + robot_ip: str, +) -> dict[str, Any]: + from unitree_webrtc_connect.constants import RTC_TOPIC + from unitree_webrtc_connect.webrtc_driver import UnitreeWebRTCConnection, WebRTCConnectionMethod + + async def publish() -> None: + connection = UnitreeWebRTCConnection(WebRTCConnectionMethod.LocalSTA, ip=robot_ip) + await connection.connect() + try: + await connection.datachannel.disableTrafficSaving(True) + deadline = time.monotonic() + max(0.0, duration_s) + while time.monotonic() < deadline: + connection.datachannel.pub_sub.publish_without_callback( + RTC_TOPIC["WIRELESS_CONTROLLER"], + data=payload, + ) + await asyncio.sleep(0.02) + for _ in range(HARD_STOP_REPEATS): + connection.datachannel.pub_sub.publish_without_callback( + RTC_TOPIC["WIRELESS_CONTROLLER"], + data={"lx": 0, "ly": 0, "rx": 0, "ry": 0}, + ) + await asyncio.sleep(HARD_STOP_INTERVAL_S) + finally: + await connection.disconnect() + + asyncio.run(publish()) + return {"observed": False, "transport": "unitree_webrtc_wireless_controller", "ok": True} + + +def _run_robot_posture(command: str, robot_ip: str) -> bool: + session = _get_robot_session(robot_ip) + try: + return session.posture(command) + finally: + if command == "sleep": + _close_robot_session(robot_ip) + + +def _run_robot_go_to(x: float, y: float) -> dict[str, Any]: + return _call_dimos_mcp_skill("go_to", {"x": x, "y": y}) + + +def _run_robot_scan_zone(zone_id: str) -> dict[str, Any]: + return _call_dimos_mcp_skill("scan_zone", {"zone_id": zone_id}) + + +def _run_route_hard_stop(robot_ip: str) -> dict[str, Any]: + return _publish_robot_hard_stop(robot_ip) + + +class _DirectRouteGoalPublisher: + transport_name = "direct_webrtc" + + def __init__( + self, + robot_ip: str, + live_snapshot_reader: Any, + *, + reach_radius_m: float = DIRECT_ROUTE_REACH_RADIUS_M, + waypoint_timeout_s: float = DIRECT_ROUTE_WAYPOINT_TIMEOUT_S, + ) -> None: + self.robot_ip = robot_ip + self.live_snapshot_reader = live_snapshot_reader + self.reach_radius_m = reach_radius_m + self.waypoint_timeout_s = waypoint_timeout_s + self._lock = threading.RLock() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + + def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]: + del z, frame_id + with self._lock: + self._stop_event.set() + previous = self._thread + if previous is not None and previous.is_alive(): + previous.join(timeout=0.5) + self._stop_event = threading.Event() + self._thread = threading.Thread( + target=self._drive_to_waypoint, + args=(x, y, self._stop_event), + daemon=True, + ) + self._thread.start() + return {"transport": self.transport_name, "x": x, "y": y} + + def stop(self) -> None: + with self._lock: + self._stop_event.set() + thread = self._thread + if thread is not None and thread.is_alive(): + thread.join(timeout=1.0) + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.get(self.robot_ip) + if session is not None and not session.closed: + try: + session.hard_stop() + except Exception: + pass + + def _drive_to_waypoint(self, x: float, y: float, stop_event: threading.Event) -> None: + session = _get_robot_session(self.robot_ip) + deadline = time.time() + self.waypoint_timeout_s + try: + while not stop_event.is_set() and time.time() < deadline: + pose = _live_robot_pose(self.live_snapshot_reader()) + if pose is None: + time.sleep(0.05) + continue + dx = x - pose["x"] + dy = y - pose["y"] + distance = math.hypot(dx, dy) + if distance <= self.reach_radius_m: + session.hard_stop() + return + yaw = math.radians(float(pose.get("theta_deg") or 0.0)) + forward = math.cos(yaw) * dx + math.sin(yaw) * dy + lateral = -math.sin(yaw) * dx + math.cos(yaw) * dy + linear_x = _clamp( + forward * DIRECT_ROUTE_GAIN, + -DIRECT_ROUTE_MAX_SPEED, + DIRECT_ROUTE_MAX_SPEED, + ) + linear_y = _clamp( + lateral * DIRECT_ROUTE_GAIN, + -DIRECT_ROUTE_MAX_SPEED, + DIRECT_ROUTE_MAX_SPEED, + ) + target_heading = math.atan2(dy, dx) + heading_error = _wrap_angle(target_heading - yaw) + angular_z = _clamp( + heading_error * 0.6, + -DIRECT_ROUTE_MAX_ANGULAR_SPEED, + DIRECT_ROUTE_MAX_ANGULAR_SPEED, + ) + session.jog(linear_x, linear_y, angular_z, DIRECT_ROUTE_PULSE_S) + finally: + try: + session.hard_stop() + except Exception: + pass + + +def _run_robot_follow_route( + route_id: str | None, + dry_run: bool, + *, + use_planner: bool = False, + robot_ip: str = DEFAULT_ROBOT_IP, + run_dir: Path | None = None, +) -> dict[str, Any]: + if dry_run or use_planner: + return _run_robot_follow_route_with_planner(route_id, dry_run) + return _run_robot_follow_route_direct(route_id, robot_ip=robot_ip, run_dir=run_dir) + + +def _run_robot_follow_route_with_planner(route_id: str | None, dry_run: bool) -> dict[str, Any]: + args: dict[str, Any] = {"dry_run": dry_run} + if route_id: + args["route_id"] = route_id + return _call_dimos_mcp_skill("follow_route", args, timeout_s=DIMOS_ROUTE_CALL_TIMEOUT_S) + + +def _run_robot_follow_route_direct( + route_id: str | None, + *, + robot_ip: str, + run_dir: Path | None, +) -> dict[str, Any]: + route_run_dir = run_dir or Path(".dogops/runs/latest") + live_adapter = _LIVE_MAP_ADAPTER + publisher = _DirectRouteGoalPublisher(robot_ip, live_adapter.snapshot) + executor = DogOpsRouteExecutor( + route_run_dir, + goal_publisher=publisher, + live_snapshot_reader=live_adapter.snapshot, + stop_handler=lambda: _run_route_hard_stop(robot_ip), + frame="world", + reach_radius_m=DIRECT_ROUTE_REACH_RADIUS_M, + waypoint_timeout_s=DIRECT_ROUTE_WAYPOINT_TIMEOUT_S, + max_retries=0, + require_goal_confirmation=False, + ) + try: + state = executor.follow_route(route_id, dry_run=False) + finally: + publisher.stop() + return { + "ok": state.state == "completed", + "transport": "direct_webrtc", + "skill": "follow_route", + "mcp_result": { + "ok": state.state == "completed", + "skill": "follow_route", + "route_id": state.route_id, + "state": state.state, + "active_waypoint_id": state.active_waypoint_id, + "waypoints_total": state.waypoints_total, + "waypoints_reached": state.waypoints_reached, + "transport": state.transport, + "dry_run": False, + "last_error": state.last_error, + "route_execution": state.model_dump(mode="json"), + }, + } + + +def _run_robot_stop_route() -> dict[str, Any]: + return _call_dimos_mcp_skill("stop_route", {}) + + +def _run_robot_route_status() -> dict[str, Any]: + return _call_dimos_mcp_skill("route_status", {}) + + +def _mcp_route_execution(result: Any) -> dict[str, Any] | None: + if not isinstance(result, dict): + return None + mcp_result = result.get("mcp_result") + if isinstance(mcp_result, dict) and isinstance(mcp_result.get("route_execution"), dict): + return mcp_result["route_execution"] + if isinstance(result.get("route_execution"), dict): + return result["route_execution"] + return None + + +def _is_mcp_unavailable_error(exc: Exception) -> bool: + message = str(exc).lower() + return any(marker in message for marker in MCP_UNAVAILABLE_MARKERS) + + +def _call_dimos_mcp_skill( + skill_name: str, + args: dict[str, Any], + *, + timeout_s: float = DIMOS_MCP_CALL_TIMEOUT_S, +) -> dict[str, Any]: + command = _dimos_mcp_call_command(skill_name, args) + try: + result = subprocess.run( + command, + cwd=_dimos_command_cwd(), + env=_dimos_command_env(), + capture_output=True, + check=False, + text=True, + timeout=timeout_s, + ) + except FileNotFoundError as exc: + raise ModuleNotFoundError( + f"DimOS MCP command is unavailable: {command[0]}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise TimeoutError( + f"dimos mcp call {skill_name} timed out after {timeout_s:.1f}s" + ) from exc + + stdout = result.stdout.strip() + stderr = result.stderr.strip() + if result.returncode != 0: + detail = stderr or stdout or "no output" + raise RuntimeError(f"dimos mcp call {skill_name} failed: {detail}") + payload: dict[str, Any] = { + "transport": "dimos_mcp", + "skill": skill_name, + } + if stdout: + try: + decoded = json.loads(stdout) + except json.JSONDecodeError: + payload["stdout"] = stdout + else: + if isinstance(decoded, dict): + if decoded.get("ok") is False: + detail = decoded.get("error") or decoded.get("message") or decoded + raise RuntimeError(f"dimos mcp call {skill_name} returned error: {detail}") + payload["mcp_result"] = decoded + else: + payload["mcp_result"] = {"value": decoded} + return payload + + +def _dimos_mcp_call_command(skill_name: str, args: dict[str, Any]) -> list[str]: + encoded_args = json.dumps(args, separators=(",", ":")) + raw_prefix = os.environ.get("DOGOPS_DIMOS_MCP_CALL") + if raw_prefix: + return [*shlex.split(raw_prefix), skill_name, "--json-args", encoded_args] + if shutil.which("uv") is not None: + return ["uv", "run", "dimos", "mcp", "call", skill_name, "--json-args", encoded_args] + return ["dimos", "mcp", "call", skill_name, "--json-args", encoded_args] + + +def _dimos_command_cwd() -> str | None: + for name in ("DOGOPS_DIMOS_ROOT", "DIMOS_ROOT"): + root = os.environ.get(name) + if root and Path(root).exists(): + return root + return None + + +def _dimos_command_env() -> dict[str, str]: + env = dict(os.environ) + for key in ("NO_PROXY", "no_proxy"): + existing = env.get(key, "") + entries = [item.strip() for item in existing.split(",") if item.strip()] + for host in ("127.0.0.1", "localhost"): + if host not in entries: + entries.append(host) + env[key] = ",".join(entries) + return env + + +def _get_robot_session(robot_ip: str) -> _RobotMotionSession: + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.get(robot_ip) + if session is not None and not session.closed: + return session + + new_session = _RobotMotionSession(robot_ip) + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.get(robot_ip) + if session is not None and not session.closed: + new_session.close() + return session + _ROBOT_SESSIONS[robot_ip] = new_session + return new_session + + +def _close_robot_session(robot_ip: str) -> None: + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.pop(robot_ip, None) + if session is not None: + session.close() + + +def _robot_pose_snapshot(robot_ip: str) -> dict[str, Any]: + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.get(robot_ip) + if session is None or session.closed: + return { + "ok": False, + "connected": False, + "source": "unitree_go2_odom", + "robot_ip": robot_ip, + "error": "robot_session_not_started", + } + return session.pose_snapshot() + + +def _start_robot_map(robot_ip: str) -> dict[str, Any]: + return _get_robot_session(robot_ip).pose_snapshot() + + +def _reset_robot_map_origin(robot_ip: str) -> bool: + with _ROBOT_SESSIONS_LOCK: + session = _ROBOT_SESSIONS.get(robot_ip) + if session is None or session.closed: + return False + return session.reset_map_origin() + + +class _RobotMotionSession: + def __init__(self, robot_ip: str) -> None: + from unitree_webrtc_connect.constants import RTC_TOPIC, SPORT_CMD + + self.robot_ip = robot_ip + self.rtc_topic = RTC_TOPIC + self.sport_cmd = SPORT_CMD + self.connection = self._make_connection(robot_ip) + self.lock = threading.RLock() + self.pose_lock = threading.RLock() + self.closed = False + self.mode = "connected" + self._latest_pose: tuple[float, float, float] | None = None + self._latest_pose_ts: float | None = None + self._map_origin_pose: tuple[float, float, float] | None = None + self._pose_history: deque[tuple[float, float, float, float]] = deque( + maxlen=ROBOT_POSE_HISTORY_LIMIT + ) + self._odom_subscription = self.connection.raw_odom_stream().subscribe(self._set_pose) + + def _make_connection(self, robot_ip: str) -> Any: + from dimos.robot.unitree.connection import UnitreeWebRTCConnection + + return UnitreeWebRTCConnection(robot_ip) + + def posture(self, command: str) -> bool: + with self.lock: + if command == "wake": + ok = bool(self._sport("StandUp")) + time.sleep(3.0) + self._sport("BalanceStand") + self.mode = "balance" + return ok + if command == "balance": + self._sport("BalanceStand") + self.mode = "balance" + return True + if command == "sleep": + self.hard_stop() + self._sport("StandDown") + self.mode = "sleep" + return True + raise ValueError(f"unknown posture command: {command}") + + def jog( + self, + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + ) -> dict[str, Any]: + with self.lock: + restore_obstacles = bool(linear_x or linear_y) + try: + self._ensure_motion_ready(disable_obstacles=restore_obstacles) + before = self._wait_pose() + self._sport_move(linear_x, linear_y, angular_z) + time.sleep(duration_s) + self.hard_stop() + after = self._wait_pose() + return _pose_delta(before, after) + finally: + if restore_obstacles: + self.connection.set_obstacle_avoidance(True) + + def hard_stop(self) -> None: + with self.lock: + try: + self._sport("StopMove") + except Exception: + pass + for _ in range(HARD_STOP_REPEATS): + self._send_joystick({"lx": 0, "ly": 0, "rx": 0, "ry": 0}) + time.sleep(HARD_STOP_INTERVAL_S) + + def close(self) -> None: + if self.closed: + return + self.closed = True + try: + self._odom_subscription.dispose() + except Exception: + pass + try: + self.connection.stop() + except Exception: + pass + + def pose_snapshot(self) -> dict[str, Any]: + with self.pose_lock: + latest = self._latest_pose + latest_ts = self._latest_pose_ts + origin = self._map_origin_pose + history = list(self._pose_history) + if latest is None or latest_ts is None or origin is None: + return { + "ok": False, + "connected": not self.closed, + "source": "unitree_go2_odom", + "robot_ip": self.robot_ip, + "error": "waiting_for_odom", + } + x, y, yaw = _relative_map_pose(latest, origin) + return { + "ok": True, + "connected": not self.closed, + "source": "unitree_go2_odom", + "robot_ip": self.robot_ip, + "ts": latest_ts, + "pose": {"x": x, "y": y, "yaw_rad": yaw}, + "raw_pose": {"x": latest[0], "y": latest[1], "yaw_rad": latest[2]}, + "origin_raw_pose": {"x": origin[0], "y": origin[1], "yaw_rad": origin[2]}, + "trajectory": [ + {"x": item[0], "y": item[1], "yaw_rad": item[2], "ts": item[3]} + for item in history + ], + } + + def reset_map_origin(self) -> bool: + with self.pose_lock: + if self._latest_pose is None: + return False + now = self._latest_pose_ts or time.time() + self._map_origin_pose = self._latest_pose + x, y, yaw = _relative_map_pose(self._latest_pose, self._map_origin_pose) + self._pose_history.clear() + self._pose_history.append((x, y, yaw, now)) + return True + + def _ensure_balance(self) -> None: + if self.mode != "balance": + self._sport("BalanceStand") + self.mode = "balance" + + def _ensure_motion_ready(self, *, disable_obstacles: bool) -> None: + if disable_obstacles: + self.connection.set_obstacle_avoidance(False) + self._ensure_balance() + + def _sport(self, command: str) -> Any: + return self._request( + self.rtc_topic["SPORT_MOD"], + {"api_id": self.sport_cmd[command]}, + ) + + def _sport_move(self, linear_x: float, linear_y: float, angular_z: float) -> Any: + return self._request( + self.rtc_topic["SPORT_MOD"], + { + "api_id": self.sport_cmd["Move"], + "parameter": {"x": linear_x, "y": linear_y, "z": angular_z}, + }, + ) + + def _request(self, topic: str, data: dict[str, Any]) -> Any: + async def send_request() -> Any: + return await self.connection.conn.datachannel.pub_sub.publish_request_new(topic, data) + + future = asyncio.run_coroutine_threadsafe(send_request(), self.connection.loop) + try: + result = future.result(timeout=WEBRTC_COMMAND_TIMEOUT_S) + except FutureTimeoutError as exc: + self.closed = True + raise TimeoutError(f"WebRTC request timed out after {WEBRTC_COMMAND_TIMEOUT_S:.1f}s") from exc + status_code = _response_status_code(result) + if status_code not in {None, 0}: + raise RuntimeError(f"WebRTC request failed with status code {status_code}") + return result + + def _send_joystick(self, data: dict[str, float | int]) -> None: + async def send_joystick() -> None: + self.connection.conn.datachannel.pub_sub.publish_without_callback( + self.rtc_topic["WIRELESS_CONTROLLER"], + data=data, + ) + + future = asyncio.run_coroutine_threadsafe(send_joystick(), self.connection.loop) + try: + future.result(timeout=WEBRTC_COMMAND_TIMEOUT_S) + except FutureTimeoutError as exc: + self.closed = True + raise TimeoutError(f"WebRTC joystick timed out after {WEBRTC_COMMAND_TIMEOUT_S:.1f}s") from exc + + def _set_pose(self, msg: Any) -> None: + pose = _pose_xy_yaw(msg) + now = time.time() + with self.pose_lock: + if self._map_origin_pose is None: + self._map_origin_pose = pose + self._latest_pose = pose + self._latest_pose_ts = now + x, y, yaw = _relative_map_pose(pose, self._map_origin_pose) + self._pose_history.append((x, y, yaw, now)) + + def _wait_pose(self) -> tuple[float, float, float] | None: + deadline = time.time() + 1.0 + while time.time() < deadline: + if self._latest_pose is not None: + return self._latest_pose + time.sleep(0.02) + return None + + +def _joystick_payload(linear_x: float, linear_y: float, angular_z: float) -> dict[str, float | int]: + return { + "lx": -linear_y, + "ly": linear_x, + "rx": -angular_z, + "ry": 0, + } + + +def _pose_xy_yaw(msg: Any) -> tuple[float, float, float]: + pose = msg["data"]["pose"] if isinstance(msg, dict) else msg + position = pose["position"] if isinstance(pose, dict) else pose.position + orientation = pose["orientation"] if isinstance(pose, dict) else pose.orientation + x = float(position["x"] if isinstance(position, dict) else position.x) + y = float(position["y"] if isinstance(position, dict) else position.y) + qx = float(orientation["x"] if isinstance(orientation, dict) else orientation.x) + qy = float(orientation["y"] if isinstance(orientation, dict) else orientation.y) + qz = float(orientation["z"] if isinstance(orientation, dict) else orientation.z) + qw = float(orientation["w"] if isinstance(orientation, dict) else orientation.w) + yaw = math.atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz)) + return x, y, yaw + + +def _relative_map_pose( + pose: tuple[float, float, float], + origin: tuple[float, float, float], +) -> tuple[float, float, float]: + dx = pose[0] - origin[0] + dy = pose[1] - origin[1] + heading = -origin[2] + cos_h = math.cos(heading) + sin_h = math.sin(heading) + x = (dx * cos_h) - (dy * sin_h) + y = (dx * sin_h) + (dy * cos_h) + yaw = _wrap_angle(pose[2] - origin[2]) + return x, y, yaw + + +def _wrap_angle(value: float) -> float: + return math.atan2(math.sin(value), math.cos(value)) + + +def _pose_delta( + before: tuple[float, float, float] | None, + after: tuple[float, float, float] | None, +) -> dict[str, Any]: + if before is None or after is None: + return {"observed": False} + dx = after[0] - before[0] + dy = after[1] - before[1] + dyaw = _wrap_angle(after[2] - before[2]) + return { + "observed": True, + "observed_dx_m": dx, + "observed_dy_m": dy, + "observed_distance_m": math.hypot(dx, dy), + "observed_dyaw_rad": dyaw, + } + + +def _response_status_code(result: Any) -> int | None: + if not isinstance(result, dict): + return None + data = result.get("data") + if not isinstance(data, dict): + return None + status = data.get("status") + if not isinstance(status, dict): + return None + code = status.get("code") + return int(code) if code is not None else None diff --git a/dimos/experimental/dogops/dashboard_static.py b/dimos/experimental/dogops/dashboard_static.py new file mode 100644 index 0000000000..8dd08afecf --- /dev/null +++ b/dimos/experimental/dogops/dashboard_static.py @@ -0,0 +1,4346 @@ +from __future__ import annotations + +from html import escape +from itertools import pairwise +import json +import math +import os +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from dimos.experimental.dogops.map_authoring import load_map_authoring +from dimos.experimental.dogops.qr_cargo import get_latest_qr_events + +MAP_WIDTH = 920 +MAP_HEIGHT = 560 +MAP_PADDING_M = 0.55 +MAP_CELL_M = 0.18 +ENTITY_OFFSETS_M = ( + (0.0, 0.0), + (0.24, 0.22), + (0.28, -0.22), + (-0.26, 0.20), + (-0.30, -0.18), + (0.48, 0.0), + (0.0, -0.46), +) +PACKAGE_OFFSETS_M = ( + (-0.22, -0.18), + (0.0, -0.22), + (0.22, -0.18), + (-0.14, 0.18), + (0.14, 0.18), +) + + +def build_map_data( + state: dict[str, Any], + report: dict[str, Any], + *, + live_overlay: dict[str, Any] | None = None, + heatmap_snapshot: dict[str, Any] | None = None, + authoring: dict[str, Any] | None = None, + qr_events: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + site = state.get("site") or {} + authoring = authoring or {} + authored_entities = _authoring_entities(authoring) + authored_incidents = _authoring_incidents(authoring) + zones = site.get("zones") or [] + assets = site.get("assets") or [] + site_packages = site.get("packages") or [] + observations = state.get("observations") or [] + nav_events = state.get("nav_events") or [] + incidents = report.get("incidents") or [] + report_packages = report.get("packages") or [] + + zone_points: dict[str, tuple[float, float]] = {} + map_zones: list[dict[str, Any]] = [] + for zone in zones: + pose = _zone_pose(zone) + zone_id = str(zone["id"]) + authored = authored_entities.get(zone_id) + if authored is not None: + pose = _authoring_pose(authored) + elif zone_id == "HOME" and authoring.get("home"): + pose = _authoring_pose({"pose": authoring["home"]}) + if pose is None: + continue + zone_points[zone_id] = pose + map_zones.append( + { + "id": zone_id, + "display_name": _authoring_label(authoring, zone_id, zone.get("display_name") or zone_id), + "zone_kind": zone.get("zone_kind", "zone"), + "tag_id": _authoring_tag_id(authoring, zone_id, zone.get("tag_id")), + "radius_m": float(zone.get("radius_m") or 0.8), + "no_go": bool(zone.get("no_go")), + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit" if authored is not None or (zone_id == "HOME" and authoring.get("home")) else "site_config", + } + ) + + entity_points = dict(zone_points) + map_assets: list[dict[str, Any]] = [] + for index, asset in enumerate(assets): + zone_id = str(asset.get("zone_id") or "") + base = zone_points.get(zone_id) + asset_id = str(asset["id"]) + authored = authored_entities.get(asset_id) + authored_pose = _authoring_pose(authored) if authored is not None else None + if base is None and authored_pose is None: + continue + pose = authored_pose or _offset_pose(base, index + 1) # type: ignore[arg-type] + entity_points[asset_id] = pose + map_assets.append( + { + "id": asset_id, + "display_name": _authoring_label(authoring, asset_id, asset.get("display_name") or asset_id), + "asset_kind": asset.get("asset_kind", "asset"), + "tag_id": _authoring_tag_id(authoring, asset_id, asset.get("tag_id")), + "zone_id": str((authored or {}).get("zone_id") or zone_id), + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit" if authored_pose is not None else "site_config", + } + ) + + site_package_by_id = {str(package["id"]): package for package in site_packages} + package_counts_by_zone: dict[str, int] = {} + map_packages: list[dict[str, Any]] = [] + for package in report_packages: + package_id = str(package["package_id"]) + authored = authored_entities.get(package_id) + observed_zone = package.get("observed_zone_id") + expected_zone = package.get("expected_zone_id") + zone_id = str(observed_zone or expected_zone or "") + base = zone_points.get(zone_id) + authored_pose = _authoring_pose(authored) if authored is not None else None + if base is None and authored_pose is None: + continue + count = package_counts_by_zone.get(zone_id, 0) + package_counts_by_zone[zone_id] = count + 1 + pose = authored_pose or _package_pose(base, count) # type: ignore[arg-type] + site_package = site_package_by_id.get(package_id) or {} + entity_points[package_id] = pose + map_packages.append( + { + "id": package_id, + "display_name": _authoring_label(authoring, package_id, site_package.get("display_name") or package_id), + "tag_id": _authoring_tag_id(authoring, package_id, site_package.get("tag_id")), + "expected_zone_id": expected_zone, + "observed_zone_id": observed_zone, + "state": package.get("state", "unknown"), + "blocks_asset_id": package.get("blocks_asset_id"), + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit" if authored_pose is not None else "site_config", + } + ) + + for entity in (authoring.get("entities") or []): + if not isinstance(entity, dict): + continue + entity_id = str(entity.get("id") or "") + if not entity_id or entity_id in entity_points: + continue + pose = _authoring_pose(entity) + if pose is None: + continue + kind = str(entity.get("kind") or "checkpoint") + entity_points[entity_id] = pose + if kind in {"zone", "checkpoint"}: + zone_points[entity_id] = pose + map_zones.append( + { + "id": entity_id, + "display_name": entity.get("label") or entity_id, + "zone_kind": kind, + "tag_id": entity.get("tag_id"), + "radius_m": 0.45, + "no_go": False, + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit", + } + ) + elif kind == "asset": + map_assets.append( + { + "id": entity_id, + "display_name": entity.get("label") or entity_id, + "asset_kind": "authored", + "tag_id": entity.get("tag_id"), + "zone_id": entity.get("zone_id"), + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit", + } + ) + elif kind == "package": + map_packages.append( + { + "id": entity_id, + "display_name": entity.get("label") or entity_id, + "tag_id": entity.get("tag_id"), + "expected_zone_id": entity.get("zone_id"), + "observed_zone_id": entity.get("zone_id"), + "state": "authored", + "blocks_asset_id": None, + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit", + } + ) + + map_route: list[dict[str, Any]] = [] + authored_route = _selected_authoring_route(authoring) + if authored_route is not None: + for waypoint in authored_route.get("waypoints") or []: + if not isinstance(waypoint, dict): + continue + pose = _authoring_pose(waypoint) + if pose is None: + continue + target_id = str(waypoint.get("target_id") or waypoint.get("id") or "waypoint") + map_route.append( + { + "target_id": target_id, + "x": pose[0], + "y": pose[1], + "success": True, + "guided": False, + "retries": 0, + "note": "authored route", + "source": "dashboard_edit", + } + ) + else: + for event in nav_events: + if event.get("action") != "goto": + continue + target_id = str(event.get("target_id") or "") + pose = entity_points.get(target_id) + if pose is None: + continue + map_route.append( + { + "target_id": target_id, + "x": pose[0], + "y": pose[1], + "success": bool(event.get("success", True)), + "guided": bool(event.get("guided", False)), + "retries": int(event.get("retries") or 0), + "note": event.get("note", ""), + "source": "nav_event", + } + ) + + map_observations: list[dict[str, Any]] = [] + for observation in observations: + zone_id = str(observation.get("zone_id") or "") + base = zone_points.get(zone_id) + if base is None: + continue + pose = _offset_pose(base, len(map_observations) + 1) + map_observations.append( + { + "id": observation.get("id"), + "zone_id": zone_id, + "entity_id": observation.get("entity_id"), + "tag_id": observation.get("tag_id"), + "visible_tag_ids": _visible_tag_ids(observation), + "source": observation.get("source", "unknown"), + "x": pose[0], + "y": pose[1], + } + ) + + map_incidents: list[dict[str, Any]] = [] + for incident in incidents: + entity_id = str(incident.get("entity_id") or "") + location = authored_incidents.get(str(incident.get("id") or "")) + pose = _authoring_pose(location) if location is not None else entity_points.get(entity_id) + if pose is None and incident.get("related_package_id"): + pose = entity_points.get(str(incident["related_package_id"])) + if pose is None: + continue + map_incidents.append( + { + "id": incident.get("id"), + "entity_id": entity_id, + "related_package_id": incident.get("related_package_id"), + "severity": incident.get("severity", "INFO"), + "state": incident.get("state", "unknown"), + "x": pose[0], + "y": pose[1], + "source": "dashboard_edit" if location is not None else "run_state", + } + ) + + map_qr_cargo_events = _qr_cargo_overlays(qr_events or [], entity_points) + + live = live_overlay or { + "ok": False, + "source": "DimOS live LCM topics", + "status": "not_requested", + "error": "", + "topics": {}, + "costmap": None, + "path": [], + "route": [], + "robot_pose": None, + "target": None, + } + live = _live_with_gathered_heatmap(live, heatmap_snapshot) + points = [ + (item["x"], item["y"]) + for group in (map_zones, map_assets, map_packages, map_route, map_observations) + for item in group + ] + points.extend( + (item["x"], item["y"]) + for item in map_qr_cargo_events + if item.get("x") is not None and item.get("y") is not None + ) + points.extend(_no_go_shape_points(authoring)) + points.extend(_live_overlay_points(live)) + bounds = _map_bounds(points) + return { + "site_id": site.get("site_id"), + "site_name": site.get("site_name"), + "zones": map_zones, + "assets": map_assets, + "packages": map_packages, + "route": map_route, + "observations": map_observations, + "incidents": map_incidents, + "qr_cargo_events": map_qr_cargo_events, + "no_go_shapes": authoring.get("no_go_shapes") or [], + "tag_bindings": authoring.get("tag_bindings") or [], + "authoring": { + "schema_version": authoring.get("schema_version", 1), + "updated_at": authoring.get("updated_at"), + "selected_route_id": authoring.get("selected_route_id"), + "entities": len(authoring.get("entities") or []), + "no_go_shapes": len(authoring.get("no_go_shapes") or []), + "routes": len(authoring.get("routes") or []), + "tag_bindings": len(authoring.get("tag_bindings") or []), + }, + "bounds": bounds, + "live": live, + "gathered_heatmap": _gathered_heatmap_summary(heatmap_snapshot), + "layers": { + "semantic": True, + "heatmap": bool((live.get("costmap") or {}).get("cells")) if isinstance(live, dict) else False, + "path": bool(live.get("path") or live.get("route")) if isinstance(live, dict) else False, + "robot": bool(live.get("robot_pose")) if isinstance(live, dict) else False, + "qr": bool(map_qr_cargo_events), + }, + } + + +def _live_with_gathered_heatmap( + live: dict[str, Any], + heatmap_snapshot: dict[str, Any] | None, +) -> dict[str, Any]: + if not isinstance(heatmap_snapshot, dict): + return live + costmap = heatmap_snapshot.get("costmap") + cells = costmap.get("cells") if isinstance(costmap, dict) else None + if not isinstance(cells, list) or not cells: + return live + result = dict(live) + result["costmap"] = { + **costmap, + "source": f"Gathered heatmap {heatmap_snapshot.get('route_run_id') or ''}".strip(), + } + result["source"] = result["costmap"]["source"] + result["status"] = "gathered_heatmap" + result["gathered_heatmap"] = _gathered_heatmap_summary(heatmap_snapshot) + if not result.get("ok"): + result["ok"] = True + return result + + +def _gathered_heatmap_summary(snapshot: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(snapshot, dict): + return None + costmap = snapshot.get("costmap") if isinstance(snapshot.get("costmap"), dict) else {} + cells = costmap.get("cells") if isinstance(costmap, dict) else [] + return { + "route_run_id": snapshot.get("route_run_id"), + "area_id": snapshot.get("area_id"), + "collected_at": snapshot.get("collected_at"), + "cells": len(cells) if isinstance(cells, list) else 0, + "source": snapshot.get("source"), + } + + +def _live_overlay_points(live: dict[str, Any]) -> list[tuple[float, float]]: + points: list[tuple[float, float]] = [] + for point in [*(live.get("path") or []), *(live.get("route") or [])]: + maybe_point = _xy_point(point) + if maybe_point is not None: + points.append(maybe_point) + for point in (live.get("robot_pose"), live.get("target")): + maybe_point = _xy_point(point) + if maybe_point is not None: + points.append(maybe_point) + costmap = live.get("costmap") or {} + cells = costmap.get("cells") if isinstance(costmap, dict) else None + if isinstance(cells, list): + for cell in cells: + maybe_point = _xy_point(cell) + if maybe_point is None: + continue + x, y = maybe_point + width = _float_or_none(cell.get("width") if isinstance(cell, dict) else None) or 0.0 + height = _float_or_none(cell.get("height") if isinstance(cell, dict) else None) or 0.0 + points.append((x, y)) + points.append((x + width, y + height)) + return points + + +def _authoring_entities(authoring: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + str(entity.get("id")): entity + for entity in authoring.get("entities") or [] + if isinstance(entity, dict) and entity.get("id") + } + + +def _authoring_incidents(authoring: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + str(location.get("incident_id")): location + for location in authoring.get("incident_locations") or [] + if isinstance(location, dict) and location.get("incident_id") + } + + +def _authoring_pose(item: dict[str, Any] | None) -> tuple[float, float] | None: + if not isinstance(item, dict): + return None + pose = item.get("pose") if isinstance(item.get("pose"), dict) else item + if not isinstance(pose, dict): + return None + x = _float_or_none(pose.get("x")) + y = _float_or_none(pose.get("y")) + if x is None or y is None: + return None + return x, y + + +def _authoring_label(authoring: dict[str, Any], entity_id: str, fallback: object) -> str: + entity = _authoring_entities(authoring).get(entity_id) + if entity is None: + return str(fallback) + return str(entity.get("label") or fallback) + + +def _authoring_tag_id( + authoring: dict[str, Any], + entity_id: str, + fallback: object | None, +) -> object | None: + entity = _authoring_entities(authoring).get(entity_id) + if entity is not None and entity.get("tag_id") is not None: + return entity.get("tag_id") + for binding in authoring.get("tag_bindings") or []: + if isinstance(binding, dict) and binding.get("entity_id") == entity_id: + return binding.get("tag_id") + return fallback + + +def _selected_authoring_route(authoring: dict[str, Any]) -> dict[str, Any] | None: + routes = [route for route in authoring.get("routes") or [] if isinstance(route, dict)] + if not routes: + return None + selected_route_id = authoring.get("selected_route_id") + if selected_route_id: + for route in routes: + if route.get("id") == selected_route_id and route.get("waypoints"): + return route + routes_with_points = [route for route in routes if route.get("waypoints")] + return routes_with_points[0] if routes_with_points else None + + +def _no_go_shape_points(authoring: dict[str, Any]) -> list[tuple[float, float]]: + points: list[tuple[float, float]] = [] + for shape in authoring.get("no_go_shapes") or []: + if not isinstance(shape, dict) or not shape.get("enabled", True): + continue + for point in shape.get("points") or []: + maybe_point = _authoring_pose(point) + if maybe_point is not None: + points.append(maybe_point) + return points + + +def _xy_point(item: Any) -> tuple[float, float] | None: + if not isinstance(item, dict): + return None + x = _float_or_none(item.get("x")) + y = _float_or_none(item.get("y")) + if x is None or y is None: + return None + return x, y + + +def _float_or_none(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(result): + return None + return result + + +def _qr_cargo_overlays( + qr_events: list[dict[str, Any]], + entity_points: dict[str, tuple[float, float]], +) -> list[dict[str, Any]]: + overlays: list[dict[str, Any]] = [] + for event in qr_events: + payload = event.get("qr_payload") if isinstance(event.get("qr_payload"), dict) else {} + location_node_id = str(payload.get("location_node_id") or "") + static_xy = entity_points.get(location_node_id) + static_pose = _qr_static_pose(static_xy) if static_xy is not None else None + detection_pose = _qr_detection_pose(event.get("robot_pose_at_detection")) + map_position = detection_pose or static_pose + pose_delta = _qr_pose_delta(detection_pose, static_pose) + + overlay = { + "event_id": event.get("event_id"), + "warehouse_id": payload.get("warehouse_id"), + "location_node_id": location_node_id, + "zone": payload.get("zone"), + "shelf_id": payload.get("shelf_id"), + "cargo_id": payload.get("cargo_id"), + "task": payload.get("task"), + "timestamp": event.get("timestamp"), + "status": event.get("status"), + "action_policy": event.get("action_policy") or "report_only", + "robot_pose_at_detection": event.get("robot_pose_at_detection"), + "static_location_node_pose": static_pose, + "map_position": map_position, + "pose_delta": pose_delta, + "source": event.get("source"), + "linked_entity_id": location_node_id if location_node_id in entity_points else None, + "qr_payload": payload, + } + if map_position is not None: + overlay["x"] = map_position["x"] + overlay["y"] = map_position["y"] + overlays.append(overlay) + return overlays + + +def _qr_detection_pose(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + point = _xy_point(value) + if point is None: + return None + return { + "frame": str(value.get("frame") or "map"), + "x": point[0], + "y": point[1], + "yaw": _float_or_none(value.get("yaw")), + "source": "robot_pose_at_detection", + } + + +def _qr_static_pose(point: tuple[float, float]) -> dict[str, Any]: + return { + "frame": "world", + "x": point[0], + "y": point[1], + "theta_deg": None, + "source": "site_or_authoring", + } + + +def _qr_pose_delta( + detection_pose: dict[str, Any] | None, + static_pose: dict[str, Any] | None, +) -> dict[str, float] | None: + if detection_pose is None or static_pose is None: + return None + dx = float(detection_pose["x"]) - float(static_pose["x"]) + dy = float(detection_pose["y"]) - float(static_pose["y"]) + return {"dx": dx, "dy": dy, "distance_m": math.hypot(dx, dy)} + + +def build_route_data( + state: dict[str, Any], + report: dict[str, Any], + *, + authoring: dict[str, Any] | None = None, +) -> dict[str, Any]: + map_data = build_map_data(state, report, authoring=authoring) + checkpoints = { + str(checkpoint.get("target_id")): checkpoint + for checkpoint in report.get("checkpoint_verifications") or [] + } + nav_events = [event for event in state.get("nav_events") or [] if event.get("action") == "goto"] + nav_by_target = {str(event.get("target_id")): event for event in nav_events} + stops = [] + for index, stop in enumerate(map_data["route"], 1): + target_id = str(stop["target_id"]) + event = nav_by_target.get(target_id) or {} + checkpoint = checkpoints.get(target_id) or {} + stops.append( + { + "sequence": index, + "target_id": target_id, + "x": stop["x"], + "y": stop["y"], + "success": bool(stop.get("success", True)), + "guided": bool(stop.get("guided", False)), + "retries": int(stop.get("retries") or 0), + "elapsed_s": float(event.get("elapsed_s") or 0.0), + "note": stop.get("note", ""), + "expected_tag_id": checkpoint.get("expected_tag_id"), + "verification_observation_id": checkpoint.get("observation_id"), + "tag_verified": bool(checkpoint.get("verified", False)), + } + ) + nav = report.get("nav_summary") or {} + return { + "run_id": report.get("run_id"), + "mission_id": report.get("mission_id"), + "route_targets": nav.get("route_targets", len(stops)), + "route_coverage": nav.get("route_coverage", 0.0), + "waypoints_reached": nav.get("waypoints_reached", 0), + "waypoints_total": nav.get("waypoints_total", len(stops)), + "tag_reacquisition_attempts": nav.get("tag_reacquisition_attempts", 0), + "tag_reacquisition_successes": nav.get("tag_reacquisition_successes", 0), + "stops": stops, + } + + +def build_poi_data(state: dict[str, Any], report: dict[str, Any]) -> dict[str, Any]: + observations = state.get("observations") or [] + incidents = report.get("incidents") or [] + captures = [] + for observation in observations: + observation_id = str(observation.get("id") or "") + related_incident_ids = [ + str(incident.get("id")) + for incident in incidents + if observation_id in (incident.get("evidence_observation_ids") or []) + ] + captures.append( + { + "id": observation_id, + "zone_id": observation.get("zone_id"), + "entity_id": observation.get("entity_id"), + "tag_id": observation.get("tag_id"), + "visible_tag_ids": _visible_tag_ids(observation), + "source": observation.get("source", "unknown"), + "related_incident_ids": related_incident_ids, + } + ) + + readings = [] + for asset in (state.get("site") or {}).get("assets") or []: + asset_id = str(asset.get("id") or "") + expected_state = asset.get("expected_state") or {} + if asset.get("expected_clear") is not None: + raw_clear = _latest_fact_value(observations, f"{asset_id}.clearance_clear") + clearance_clear = _to_bool(raw_clear) + if clearance_clear is None: + clearance_clear = bool(asset.get("expected_clear")) + readings.append( + { + "asset_id": asset_id, + "kind": "clearance", + "state": "clear" if clearance_clear else "blocked", + "clearance_clear": clearance_clear, + "expected_clear": asset.get("expected_clear"), + } + ) + threshold = _to_float(expected_state.get("max_celsius")) + if threshold is not None: + reading = _to_float(_latest_fact_value(observations, f"{asset_id}.temperature_c")) + source = "observation" + if reading is None: + reading = _to_float(expected_state.get("current_celsius")) + source = "expected_state" + if reading is None: + reading = round(threshold - 2.0, 1) + source = "deterministic_fallback" + readings.append( + { + "asset_id": asset_id, + "kind": "temperature", + "reading_celsius": reading, + "max_celsius": threshold, + "within_threshold": reading <= threshold, + "source": source, + } + ) + return { + "run_id": report.get("run_id"), + "captures": captures, + "readings": readings, + } + + +def _route_action_count(route: dict[str, Any]) -> int: + return sum( + len(waypoint.get("actions") or []) + for waypoint in route.get("waypoints") or [] + if isinstance(waypoint, dict) + ) + + +def _route_action_rows(route: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for waypoint in route.get("waypoints") or []: + if not isinstance(waypoint, dict): + continue + for action in waypoint.get("actions") or []: + if not isinstance(action, dict): + continue + rows.append( + { + "waypoint": waypoint.get("label") or waypoint.get("id") or "-", + "kind": action.get("kind") or "-", + "label": action.get("label") or action.get("kind") or "-", + "args": action.get("args") or {}, + } + ) + return rows + + +def _render_saved_route_rows(authoring: dict[str, Any]) -> str: + routes = [route for route in authoring.get("routes") or [] if isinstance(route, dict)] + if not routes: + return 'No saved routes' + selected_route_id = authoring.get("selected_route_id") + rows: list[str] = [] + for route in routes: + route_id = str(route.get("id") or "") + selected = route_id == selected_route_id + waypoints = route.get("waypoints") or [] + rows.append( + f'' + f"{escape(str(route.get('label') or route_id))}
" + f"{escape(route_id or '-')}" + f"{'Yes' if selected else ''}" + f"{len(waypoints)}" + f"{_route_action_count(route)}" + "-" + '
' + f'' + f'' + f'' + f'' + "
" + ) + if selected: + action_rows = _route_action_rows(route) + if action_rows: + detail = ( + '
    ' + + "".join( + "
  1. " + f"{escape(str(action['waypoint']))}: " + f"{escape(str(action['label']))} " + f"({escape(str(action['kind']))}) " + f"{escape(json.dumps(action['args'], sort_keys=True))}" + "
  2. " + for action in action_rows + ) + + "
" + ) + else: + detail = "No actions saved for this route" + rows.append(f'{detail}') + return "".join(rows) + + +def render_site_map( + state: dict[str, Any], + report: dict[str, Any], + *, + authoring: dict[str, Any] | None = None, + qr_events: list[dict[str, Any]] | None = None, +) -> str: + map_data = build_map_data(state, report, authoring=authoring, qr_events=qr_events) + if not map_data["zones"]: + return '
Map data unavailable
' + + bounds = map_data["bounds"] + bounds_attr = escape(json.dumps(bounds, separators=(",", ":")), quote=True) + authoring_attr = escape(json.dumps(authoring or {}, separators=(",", ":")), quote=True) + projector = _MapProjector(bounds) + route_points = " ".join( + f"{projector.x(point['x']):.1f},{projector.y(point['y']):.1f}" + for point in map_data["route"] + ) + grid = _render_grid(projector) + floor_cells = _render_floor_cells(projector, map_data) + point_cloud = _render_point_cloud(projector, map_data) + no_go = "".join(_render_no_go_zone(projector, zone) for zone in map_data["zones"]) + no_go += "".join( + _render_no_go_shape(projector, shape) for shape in map_data["no_go_shapes"] + ) + zones = "".join(_render_zone(projector, zone) for zone in map_data["zones"]) + assets = "".join(_render_asset(projector, asset) for asset in map_data["assets"]) + packages = "".join(_render_package(projector, package) for package in map_data["packages"]) + observations = "".join( + _render_observation(projector, observation) for observation in map_data["observations"] + ) + incidents = "".join( + _render_incident(projector, incident) for incident in map_data["incidents"] + ) + qr_cargo = "".join( + _render_qr_cargo_event(projector, event) + for event in map_data["qr_cargo_events"] + ) + route = "" + if route_points: + route = ( + f'' + + "".join(_render_route_stop(projector, stop, index) for index, stop in enumerate(map_data["route"], 1)) + ) + robot = _render_live_robot_pose() + rerun_web_url = _trusted_rerun_web_url(os.environ.get("DOGOPS_RERUN_WEB_URL")) + rerun_web_url_attr = escape(rerun_web_url, quote=True) + legend = ( + '
' + 'free grid' + 'DimOS heatmap' + 'trajectory' + 'live odom' + 'tag return' + 'no-go cost' + 'P1/P2 event' + 'QR cargo' + "
" + ) + layer_controls = ( + '
' + '' + '' + '' + '' + '' + "
" + ) + edit_controls = ( + '
' + '
' + '' + '' + '' + '' + '' + '' + "
" + '
' + '' + '' + '' + '' + '' + '' + '' + '' + "
" + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + 'Selected route: none. Next: Route1' + "
" + "
" + ) + route_action_controls = ( + '" + ) + return f""" +
+ {_render_rerun_surface(rerun_web_url)} + {edit_controls} + + + + + + + + + + + + + + + + + {floor_cells} + {grid} + {point_cloud} + {no_go} + {zones} + {assets} + {packages} + {observations} + {incidents} + + + {route} + + + + {qr_cargo} + + + {robot} + + + {route_action_controls} + {layer_controls} + {legend} +
+ Open Rerun Web + Map command idle +
+
Map authoring idle
+
Execution: idle
+
Live odom: waiting for Go2
+
+ """ + + +def render_dashboard_html( + state: dict[str, Any], + report: dict[str, Any], + *, + robot_control_token: str | None = None, + authoring: dict[str, Any] | None = None, + qr_events: list[dict[str, Any]] | None = None, +) -> str: + run = state["run"] + nav = report.get("nav_summary") or {} + packages = report.get("packages") or [] + incidents = report.get("incidents") or [] + work_orders = report.get("work_orders") or [] + packages_metric = f"{report['packages_observed']}/{report['packages_expected']}" + nav_metric = f"{nav.get('waypoints_reached', 0)}/{nav.get('waypoints_total', 0)}" + checkpoint_metric = f"{report.get('checkpoints_verified', 0)}/{report.get('checkpoints_total', 0)}" + tag_recovery_metric = ( + f"{nav.get('tag_reacquisition_successes', 0)}/" + f"{nav.get('tag_reacquisition_attempts', 0)}" + ) + mean_target_time_metric = f"{nav.get('mean_elapsed_s', 0):.1f}s" + route_coverage_metric = f"{float(nav.get('route_coverage', 0.0)) * 100:.0f}%" + qr_events = qr_events or [] + map_html = render_site_map(state, report, authoring=authoring, qr_events=qr_events) + route_table_rows = _render_saved_route_rows(authoring or {}) + qr_map_data = build_map_data(state, report, authoring=authoring, qr_events=qr_events) + route_data = build_route_data(state, report, authoring=authoring) + poi_data = build_poi_data(state, report) + return f""" + + + + + DogOps SiteOps Agent + + + +
+
+

DogOps SiteOps Agent

+
Mission {escape(str(run["mission_id"]))} / run {escape(str(run["id"]))}
+
+
State: {escape(str(run["state"]))}
+
+
+
+
+

Mission Map

+ {map_html} +
+
+
+

DimOS Camera

+ Waiting for /color_image +
+
+ +
+ No camera frame yetWaiting for DimOS color_image on /color_image. +
+
+
+ Topic: /color_image + Frame: pending + Age: pending +
+
+
+
+
+

Run Summary

+
+ {metric("Packages", packages_metric)} + {metric("Exceptions", report["manifest_exceptions"])} + {metric("Incidents", report["incidents_opened"])} + {metric("Verified WOs", report["work_orders_verified_closed"])} + {metric("Nav", nav_metric)} + {metric("Tag Sign-In", checkpoint_metric)} + {metric("Coverage", route_coverage_metric)} +
+
+
+

QR Cargo

+ {qr_cargo_panel(qr_map_data["qr_cargo_events"])} +
+
+

Robot Control

+
+ + + +
+
+ + + + + +
+
+ + + +
+
+ + + + + + + + + +
+
+ WUpForward + SDownBack + ALeftLeft + DRightRight + QYaw L + EYaw R + SpaceEscHard stop +
+
Idle
+
+
+

Saved Routes

+
+ + + + + {route_table_rows} +
RouteSelectedWaypointsActionsLast RunManage
+
+
+
+

Route Run History

+
+ + + + + + + +
TimeRunRouteModeStateProgressMap
No route runs recorded
+
+
+
+

Current Run Timeline

+
+ + + + + + + +
#KindStateTargetNote
No active route timeline
+
+
+
+

Gemini Vision Evidence

+
+ + + + + + + +
WaypointSummaryChangeSeverityConfidenceBaseline
No Gemini analysis recorded
+
+
+
+
+

Package Reconciliation

+ {package_table(packages)} +
+
+

Incidents

+ {incident_table(incidents)} +
+
+

Work Orders

+ {work_order_table(work_orders)} +
+
+

Navigation Eval

+
+ {metric("Waypoints", nav_metric)} + {metric("Retries", nav.get("retries_total", 0))} + {metric("Guided", nav.get("guided_interventions", 0))} + {metric("Tag Recovery", tag_recovery_metric)} + {metric("Route Coverage", route_coverage_metric)} + {metric("Mean Target Time", mean_target_time_metric)} +
+
+
+

Route / POI Evidence

+
+
+

Route Stops

+ {route_table(route_data["stops"])} +
+
+

POI Evidence

+ {poi_list(poi_data)} +
+
+
+
+

Saved Images

+
+

No saved route images yet

+
+
+
+ + + +""" + + +class _MapProjector: + def __init__(self, bounds: dict[str, float]) -> None: + self.x_min = bounds["x_min"] + self.x_max = bounds["x_max"] + self.y_min = bounds["y_min"] + self.y_max = bounds["y_max"] + + def x(self, value: float) -> float: + span = max(0.1, self.x_max - self.x_min) + return ((value - self.x_min) / span) * MAP_WIDTH + + def y(self, value: float) -> float: + span = max(0.1, self.y_max - self.y_min) + return MAP_HEIGHT - (((value - self.y_min) / span) * MAP_HEIGHT) + + def radius(self, value_m: float) -> float: + span_x = max(0.1, self.x_max - self.x_min) + span_y = max(0.1, self.y_max - self.y_min) + px_per_m = min(MAP_WIDTH / span_x, MAP_HEIGHT / span_y) + return max(22.0, value_m * px_per_m) + + def size(self, value_m: float) -> float: + span_x = max(0.1, self.x_max - self.x_min) + span_y = max(0.1, self.y_max - self.y_min) + px_per_m = min(MAP_WIDTH / span_x, MAP_HEIGHT / span_y) + return max(2.0, value_m * px_per_m) + + +def _zone_pose(zone: dict[str, Any]) -> tuple[float, float] | None: + pose = zone.get("pose_hint") or {} + try: + return float(pose["x"]), float(pose["y"]) + except (KeyError, TypeError, ValueError): + return None + + +def _offset_pose(base: tuple[float, float], index: int) -> tuple[float, float]: + dx, dy = ENTITY_OFFSETS_M[index % len(ENTITY_OFFSETS_M)] + return base[0] + dx, base[1] + dy + + +def _package_pose(base: tuple[float, float], index: int) -> tuple[float, float]: + dx, dy = PACKAGE_OFFSETS_M[index % len(PACKAGE_OFFSETS_M)] + return base[0] + dx, base[1] + dy + + +def _visible_tag_ids(observation: dict[str, Any]) -> list[int]: + facts = observation.get("facts") or {} + raw = facts.get("visible_tag_ids") + if isinstance(raw, str): + tag_ids = [] + for item in raw.split(","): + item = item.strip() + if item: + try: + tag_ids.append(int(item)) + except ValueError: + continue + return tag_ids + if isinstance(raw, list): + return [int(item) for item in raw if isinstance(item, int | str)] + tag_id = observation.get("tag_id") + return [int(tag_id)] if isinstance(tag_id, int) else [] + + +def _trusted_rerun_web_url(raw_url: str | None) -> str: + fallback = "http://127.0.0.1:9877" + if not raw_url: + return fallback + try: + parsed = urlparse(raw_url) + except ValueError: + return fallback + if parsed.scheme not in {"http", "https"}: + return fallback + host = (parsed.hostname or "").lower() + if host not in {"127.0.0.1", "localhost", "::1"}: + return fallback + return raw_url + + +def _render_rerun_surface(rerun_web_url: str) -> str: + rerun_web_url_attr = escape(rerun_web_url, quote=True) + return ( + f'
' + '' + '
' + 'Rerun Web Visualization' + '
Rerun visualization standby
' + '
' + '' + f'Open' + "
" + "
" + "
" + ) + + +def _map_bounds(points: list[tuple[float, float]]) -> dict[str, float]: + if not points: + return {"x_min": -1.0, "x_max": 1.0, "y_min": -1.0, "y_max": 1.0} + xs = [point[0] for point in points] + ys = [point[1] for point in points] + x_min = min(xs) - MAP_PADDING_M + x_max = max(xs) + MAP_PADDING_M + y_min = min(ys) - MAP_PADDING_M + y_max = max(ys) + MAP_PADDING_M + if math.isclose(x_min, x_max): + x_min -= 1.0 + x_max += 1.0 + if math.isclose(y_min, y_max): + y_min -= 1.0 + y_max += 1.0 + return {"x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max} + + +def _render_floor_cells(projector: _MapProjector, map_data: dict[str, Any]) -> str: + free_cells: set[tuple[int, int]] = set() + cost_cells: set[tuple[int, int]] = set() + route_positions = [(float(point["x"]), float(point["y"])) for point in map_data["route"]] + + for x, y in _route_samples(route_positions): + _add_cells(free_cells, x, y, radius_m=0.38) + + for zone in map_data["zones"]: + x = float(zone["x"]) + y = float(zone["y"]) + if zone.get("no_go"): + _add_cells(cost_cells, x, y, radius_m=float(zone.get("radius_m") or 0.8)) + else: + _add_cells(free_cells, x, y, radius_m=0.42) + + for item in [*map_data["assets"], *map_data["packages"], *map_data["observations"]]: + _add_cells(free_cells, float(item["x"]), float(item["y"]), radius_m=0.24) + + free_cells -= cost_cells + free_markup = "".join(_render_cell(projector, cell, "map-free-cell") for cell in sorted(free_cells)) + cost_markup = "".join(_render_cell(projector, cell, "map-cost-cell") for cell in sorted(cost_cells)) + return free_markup + cost_markup + + +def _render_point_cloud(projector: _MapProjector, map_data: dict[str, Any]) -> str: + points: list[tuple[float, float, bool]] = [] + for index, observation in enumerate(map_data["observations"]): + x = float(observation["x"]) + y = float(observation["y"]) + visible_tags = observation.get("visible_tag_ids") or [] + count = max(4, len(visible_tags) * 3) + for point_index in range(count): + angle = (index * 0.91) + (point_index * 2.399) + radius = 0.05 + ((point_index % 5) * 0.035) + points.append( + ( + x + math.cos(angle) * radius, + y + math.sin(angle) * radius, + bool(visible_tags), + ) + ) + + for package in map_data["packages"]: + x = float(package["x"]) + y = float(package["y"]) + points.extend( + [ + (x - 0.035, y - 0.025, True), + (x + 0.038, y - 0.015, True), + (x + 0.004, y + 0.041, True), + ] + ) + + markup = [] + for x, y, hot in points: + css_class = "map-point hot" if hot else "map-point" + markup.append( + f'' + ) + return "".join(markup) + + +def _route_samples(route_positions: list[tuple[float, float]]) -> list[tuple[float, float]]: + if not route_positions: + return [] + samples = [route_positions[0]] + for start, end in pairwise(route_positions): + dx = end[0] - start[0] + dy = end[1] - start[1] + distance = math.hypot(dx, dy) + steps = max(1, int(distance / MAP_CELL_M)) + for step in range(1, steps + 1): + ratio = step / steps + samples.append((start[0] + dx * ratio, start[1] + dy * ratio)) + return samples + + +def _add_cells(cells: set[tuple[int, int]], x: float, y: float, *, radius_m: float) -> None: + radius_cells = max(1, math.ceil(radius_m / MAP_CELL_M)) + center_x = round(x / MAP_CELL_M) + center_y = round(y / MAP_CELL_M) + for dx in range(-radius_cells, radius_cells + 1): + for dy in range(-radius_cells, radius_cells + 1): + cell_x = center_x + dx + cell_y = center_y + dy + world_x = cell_x * MAP_CELL_M + world_y = cell_y * MAP_CELL_M + if math.hypot(world_x - x, world_y - y) <= radius_m: + cells.add((cell_x, cell_y)) + + +def _render_cell(projector: _MapProjector, cell: tuple[int, int], css_class: str) -> str: + world_x = cell[0] * MAP_CELL_M + world_y = cell[1] * MAP_CELL_M + size = projector.size(MAP_CELL_M) * 0.9 + x = projector.x(world_x) - size / 2 + y = projector.y(world_y) - size / 2 + return f'' + + +def _render_grid(projector: _MapProjector) -> str: + x_start = math.floor(projector.x_min * 2) / 2 + x_stop = math.ceil(projector.x_max * 2) / 2 + y_start = math.floor(projector.y_min * 2) / 2 + y_stop = math.ceil(projector.y_max * 2) / 2 + lines = [] + x_value = x_start + while x_value <= x_stop + 0.001: + x = projector.x(float(x_value)) + is_major = math.isclose(x_value % 1.0, 0.0, abs_tol=0.001) + css_class = "map-grid-major" if is_major else "map-grid" + lines.append( + f'' + ) + if is_major: + label = round(x_value) + lines.append( + f'{label}m' + ) + x_value += 0.5 + + y_value = y_start + while y_value <= y_stop + 0.001: + y = projector.y(float(y_value)) + is_major = math.isclose(y_value % 1.0, 0.0, abs_tol=0.001) + css_class = "map-grid-major" if is_major else "map-grid" + lines.append( + f'' + ) + if is_major: + label = round(y_value) + lines.append(f'{label}m') + y_value += 0.5 + return "".join(lines) + + +def _render_no_go_zone(projector: _MapProjector, zone: dict[str, Any]) -> str: + if not zone.get("no_go"): + return "" + x = projector.x(float(zone["x"])) + y = projector.y(float(zone["y"])) + radius = projector.size(float(zone.get("radius_m") or 0.8)) + width = radius * 1.55 + height = radius * 1.25 + title = escape(str(zone.get("display_name") or zone["id"])) + return ( + f'{title}' + f'' + f'' + "" + ) + + +def _render_no_go_shape(projector: _MapProjector, shape: dict[str, Any]) -> str: + if not isinstance(shape, dict) or not shape.get("enabled", True): + return "" + points = [_authoring_pose(point) for point in shape.get("points") or []] + points = [point for point in points if point is not None] + if len(points) < 2: + return "" + title = escape( + f"{shape.get('label') or shape.get('id') or 'No-go shape'} / " + f"{shape.get('dimos_constraint_status') or 'not_supported'}" + ) + if shape.get("shape") == "rectangle": + xs = [point[0] for point in points] + ys = [point[1] for point in points] + x1 = projector.x(min(xs)) + x2 = projector.x(max(xs)) + y1 = projector.y(max(ys)) + y2 = projector.y(min(ys)) + x = min(x1, x2) + y = min(y1, y2) + width = abs(x2 - x1) + height = abs(y2 - y1) + return ( + f'{title}' + f'' + f'' + "" + ) + svg_points = " ".join( + f"{projector.x(point[0]):.1f},{projector.y(point[1]):.1f}" for point in points + ) + return ( + f'{title}' + f'' + f'' + "" + ) + + +def _render_zone(projector: _MapProjector, zone: dict[str, Any]) -> str: + x = projector.x(float(zone["x"])) + y = projector.y(float(zone["y"])) + label = escape(str(zone["id"])) + title = escape(str(zone.get("display_name") or zone["id"])) + return ( + f'{title}' + f'' + f'' + f'' + f'{label}' + "" + ) + + +def _render_asset(projector: _MapProjector, asset: dict[str, Any]) -> str: + x = projector.x(float(asset["x"])) + y = projector.y(float(asset["y"])) + label = escape(str(asset["id"])) + title = escape(str(asset.get("display_name") or asset["id"])) + return ( + f'{title}' + f'' + f'' + f'{label}' + "" + ) + + +def _render_package(projector: _MapProjector, package: dict[str, Any]) -> str: + x = projector.x(float(package["x"])) + y = projector.y(float(package["y"])) + state = str(package.get("state") or "unknown") + label = escape(str(package["id"])) + title = escape(f"{package['id']} / {state}") + return ( + f'{title}' + f'' + f'{label}' + "" + ) + + +def _render_observation(projector: _MapProjector, observation: dict[str, Any]) -> str: + zone_x = projector.x(float(observation["x"])) + zone_y = projector.y(float(observation["y"])) + label = escape(str(observation["id"])) + title = escape( + f"{observation.get('id')} tags {','.join(str(tag) for tag in observation['visible_tag_ids'])}" + ) + return ( + f'{title}' + f'' + f'{label}' + "" + ) + + +def _render_incident(projector: _MapProjector, incident: dict[str, Any]) -> str: + x = projector.x(float(incident["x"])) + y = projector.y(float(incident["y"])) + label = escape(str(incident["id"])) + title = escape( + f"{incident.get('id')} {incident.get('severity')} {incident.get('state')}" + ) + return ( + f'{title}' + f'' + f'{label}' + "" + ) + + +def _render_qr_cargo_event(projector: _MapProjector, event: dict[str, Any]) -> str: + position = event.get("map_position") + if not isinstance(position, dict): + return "" + x = projector.x(float(position["x"])) + y = projector.y(float(position["y"])) + event_id = escape(str(event.get("event_id") or "")) + cargo_id = escape(str(event.get("cargo_id") or "cargo")) + location_node_id = escape(str(event.get("location_node_id") or "unknown")) + title = escape( + f"QR cargo {event.get('cargo_id') or 'cargo'} / " + f"{event.get('location_node_id') or 'unknown'} / " + f"{event.get('action_policy') or 'report_only'}" + ) + return ( + f'{title}' + f'' + f'' + f'{cargo_id}' + "" + ) + + +def _render_route_stop(projector: _MapProjector, stop: dict[str, Any], index: int) -> str: + x = projector.x(float(stop["x"])) + y = projector.y(float(stop["y"])) + title = escape(str(stop.get("target_id") or "route stop")) + return ( + f'{title}' + f'' + f'{index}' + "" + ) + + +def _render_live_robot_pose() -> str: + return ( + '' + '' + "Live Go2 odometry pose" + '' + '' + "" + '' + "DimOS planner target" + '' + '' + "" + '' + "DimOS go_to target" + '' + '' + '' + '' + '' + "" + ) + + +def _render_scan_item(observation: dict[str, Any]) -> str: + tag_ids = ", ".join(str(tag_id) for tag_id in observation["visible_tag_ids"]) or "none" + return ( + "
  • " + f"{escape(str(observation['id']))} " + f"{escape(str(observation['zone_id']))} / tags {escape(tag_ids)}" + "
  • " + ) + + +def metric(label: str, value: object) -> str: + return ( + '
    ' + f"{escape(label)}" + f"{escape(str(value))}" + "
    " + ) + + +def qr_cargo_panel(events: list[dict[str, Any]]) -> str: + rows = [] + for event in events[:50]: + event_id = escape(str(event.get("event_id") or "")) + timestamp = _format_timestamp(event.get("timestamp")) + robot_pose = _format_qr_robot_pose(event.get("robot_pose_at_detection")) + zone_shelf = " / ".join( + item + for item in [str(event.get("zone") or ""), str(event.get("shelf_id") or "")] + if item + ) or "unknown" + select_disabled = "" if event.get("map_position") else " disabled" + rows.append( + f'' + f"{escape(timestamp)}" + f"{escape(str(event.get('warehouse_id') or 'unknown'))}" + f"{escape(str(event.get('location_node_id') or 'unknown'))}" + f"{escape(str(event.get('cargo_id') or 'unknown'))}" + f"{escape(zone_shelf)}" + f"{escape(str(event.get('task') or 'unknown'))}" + f"{escape(str(event.get('source') or 'unknown'))}" + f"{escape(str(event.get('status') or 'unknown'))} / " + f"{escape(str(event.get('action_policy') or 'report_only'))}" + f"{escape(robot_pose)}" + '' + f'' + f'' + f'' + f'' + f'' + "" + "" + ) + if not rows: + rows.append( + 'No QR cargo events yet' + ) + return ( + '
    QR cargo idle
    ' + '
    ' + '' + '' + '' + f'{"".join(rows)}
    TimeWHLocation NodeCargoZone/ShelfTaskSourceStatus/PolicyRobot PoseActions
    ' + ) + + +def _format_timestamp(value: object) -> str: + try: + return f"{float(value):.3f}" + except (TypeError, ValueError): + return "unknown" + + +def _format_qr_robot_pose(value: object) -> str: + if not isinstance(value, dict): + return "unknown" + x = _float_or_none(value.get("x")) + y = _float_or_none(value.get("y")) + yaw = _float_or_none(value.get("yaw")) + if x is None or y is None: + return "unknown" + if yaw is None: + return f"{x:.2f}, {y:.2f}" + return f"{x:.2f}, {y:.2f}, yaw {yaw:.2f}" + + +def package_table(packages: list[dict[str, Any]]) -> str: + rows = [] + for package in packages: + state = str(package["state"]) + rows.append( + "" + f"{escape(str(package['package_id']))}" + f"{escape(str(package['expected_zone_id']))}" + f"{escape(str(package.get('observed_zone_id') or 'not observed'))}" + f"{escape(state)}" + "" + ) + return ( + "" + "" + + "".join(rows) + + "
    PackageExpectedObservedState
    " + ) + + +def incident_table(incidents: list[dict[str, Any]]) -> str: + rows = [] + for incident in incidents: + severity = str(incident["severity"]) + state = str(incident["state"]) + rows.append( + "" + f"{escape(str(incident['id']))}" + f"{escape(severity)}" + f"{escape(str(incident['title']))}" + f"{escape(state)}" + "" + ) + return ( + "" + "" + + "".join(rows) + + "
    IDSeverityTitleState
    " + ) + + +def work_order_table(work_orders: list[dict[str, Any]]) -> str: + rows = [] + for work_order in work_orders: + state = str(work_order["state"]) + rows.append( + "" + f"{escape(str(work_order['id']))}" + f"{escape(str(work_order['incident_id']))}" + f"{escape(str(work_order['assignee']))}" + f"{escape(state)}" + "" + ) + return ( + "" + "" + + "".join(rows) + + "
    IDIncidentAssigneeState
    " + ) + + +def checkpoint_table(checkpoints: list[dict[str, Any]]) -> str: + rows = [] + for checkpoint in checkpoints: + verified = bool(checkpoint.get("verified")) + state = "verified" if verified else "missing" + tag_id = checkpoint.get("expected_tag_id") + observation_id = checkpoint.get("observation_id") or "not observed" + rows.append( + "" + f"{escape(str(checkpoint['target_id']))}" + f"{escape(str(tag_id if tag_id is not None else 'none'))}" + f"{escape(str(observation_id))}" + f"{state}" + "" + ) + return ( + "" + "" + + "".join(rows) + + "
    TargetTagObservationState
    " + ) + + +def route_table(stops: list[dict[str, Any]]) -> str: + rows = [] + for stop in stops: + state = "verified" if stop.get("tag_verified") else "missing" + tag_id = stop.get("expected_tag_id") + rows.append( + "" + f"{escape(str(stop['sequence']))}" + f"{escape(str(stop['target_id']))}" + f"{escape(str(tag_id if tag_id is not None else 'none'))}" + f"{escape(str(stop.get('retries', 0)))}" + f"{state}" + "" + ) + return ( + "" + "" + + "".join(rows) + + "
    #TargetTagRetriesState
    " + ) + + +def poi_list(poi_data: dict[str, Any]) -> str: + captures = poi_data.get("captures") or [] + readings = poi_data.get("readings") or [] + items = [] + for capture in captures[:4]: + tags = ", ".join(str(tag_id) for tag_id in capture.get("visible_tag_ids") or []) or "none" + incidents = capture.get("related_incident_ids") or [] + incident_text = f" / incidents {', '.join(incidents)}" if incidents else "" + items.append( + "
  • " + f"{escape(str(capture['id']))} " + f"{escape(str(capture.get('zone_id') or 'unknown'))} / tags {escape(tags)}" + f"{escape(incident_text)}" + "
  • " + ) + for reading in readings[:3]: + if reading.get("kind") == "temperature": + label = ( + f"{reading['asset_id']} {reading['reading_celsius']}C " + f"<= {reading['max_celsius']}C" + ) + else: + label = f"{reading['asset_id']} {reading.get('state', 'unknown')}" + items.append(f"
  • {escape(str(reading['kind']))} {escape(label)}
  • ") + return '
      ' + "".join(items) + "
    " + + +def write_dashboard_html(run_dir: str | Path, *, robot_control_token: str | None = None) -> Path: + root = Path(run_dir) + state = _read_json(root / "state.json") + report = _read_json(root / "report.json") + authoring = load_map_authoring( + root, + site_id=str((state.get("site") or {}).get("site_id") or ""), + ).model_dump(mode="json") + html_path = root / "dashboard.html" + html_path.write_text( + render_dashboard_html( + state, + report, + robot_control_token=robot_control_token, + authoring=authoring, + qr_events=get_latest_qr_events(root), + ), + encoding="utf-8", + ) + return html_path + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _latest_fact_value(observations: list[dict[str, Any]], key: str) -> object | None: + for observation in reversed(observations): + facts = observation.get("facts") or {} + if key in facts: + return facts[key] + return None + + +def _to_float(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def _to_bool(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "yes", "1", "clear"}: + return True + if normalized in {"false", "no", "0", "blocked"}: + return False + return None diff --git a/dimos/experimental/dogops/detector.py b/dimos/experimental/dogops/detector.py new file mode 100644 index 0000000000..35c7f2a99a --- /dev/null +++ b/dimos/experimental/dogops/detector.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from dimos.experimental.dogops.config_loader import DEFAULT_SITE, load_site_config +from dimos.experimental.dogops.models import SiteConfig +from dimos.experimental.dogops.tag_registry import DogOpsTagRegistry, TagRegistration + + +@dataclass(frozen=True) +class DetectedTag: + tag_id: int + entity_id: str | None + entity_kind: str | None + zone_id: str | None + corners: tuple[tuple[float, float], ...] = () + center_px: tuple[float, float] | None = None + area_px: float | None = None + frame_id: str | None = None + confidence: float = 1.0 + source: str = "simulation" + + +class DogOpsTagDetector: + def __init__(self, site: SiteConfig | None = None) -> None: + self.site = site or load_site_config(DEFAULT_SITE) + self.registry = DogOpsTagRegistry(self.site) + + def detect_simulated(self, tag_ids: list[int], *, source: str = "simulation") -> list[DetectedTag]: + return [self._detected_from_registration(tag_id, source=source) for tag_id in tag_ids] + + def detect_image(self, image_path: str | Path) -> list[DetectedTag]: + cv2 = _import_cv2() + image = cv2.imread(str(image_path)) + if image is None: + raise ValueError(f"failed to read image: {image_path}") + return self.detect_array(image, source="cv2.imread") + + def detect_dimos_image(self, image: Any) -> list[DetectedTag]: + array, frame_id = _opencv_array_from_image(image) + return self.detect_array(array, source="dimos.color_image", frame_id=frame_id) + + def detect_array( + self, + image: Any, + *, + source: str = "cv2.aruco", + frame_id: str | None = None, + ) -> list[DetectedTag]: + cv2 = _import_cv2() + aruco = cv2.aruco + dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11) + parameters = aruco.DetectorParameters() + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image + if hasattr(aruco, "ArucoDetector"): + detector = aruco.ArucoDetector(dictionary, parameters) + corners, ids, _ = detector.detectMarkers(gray) + else: + corners, ids, _ = aruco.detectMarkers(gray, dictionary, parameters=parameters) + if ids is None: + return [] + detections: list[DetectedTag] = [] + for index, raw_id in enumerate(ids.flatten().tolist()): + tag_corners = tuple( + (float(point[0]), float(point[1])) for point in corners[index].reshape(-1, 2) + ) + detections.append( + self._detected_from_registration( + int(raw_id), + corners=tag_corners, + source=source, + frame_id=frame_id, + ) + ) + return detections + + def _detected_from_registration( + self, + tag_id: int, + *, + corners: tuple[tuple[float, float], ...] = (), + frame_id: str | None = None, + source: str, + ) -> DetectedTag: + registration = self.registry.get(tag_id) + return _detected_tag( + tag_id, + registration, + corners=corners, + frame_id=frame_id, + source=source, + ) + + +def _detected_tag( + tag_id: int, + registration: TagRegistration | None, + *, + corners: tuple[tuple[float, float], ...], + frame_id: str | None, + source: str, +) -> DetectedTag: + center_px = _corner_center(corners) + area_px = _corner_area(corners) + return DetectedTag( + tag_id=tag_id, + entity_id=registration.entity_id if registration else None, + entity_kind=registration.entity_kind if registration else None, + zone_id=registration.zone_id if registration else None, + corners=corners, + center_px=center_px, + area_px=area_px, + frame_id=frame_id, + confidence=1.0, + source=source, + ) + + +def _opencv_array_from_image(image: Any) -> tuple[Any, str | None]: + frame_id = getattr(image, "frame_id", None) + if hasattr(image, "to_opencv"): + return image.to_opencv(), frame_id + data = getattr(image, "data", None) + if data is not None and hasattr(data, "shape"): + return data, frame_id + return image, frame_id + + +def _corner_center(corners: tuple[tuple[float, float], ...]) -> tuple[float, float] | None: + if not corners: + return None + x = sum(point[0] for point in corners) / len(corners) + y = sum(point[1] for point in corners) / len(corners) + return (x, y) + + +def _corner_area(corners: tuple[tuple[float, float], ...]) -> float | None: + if len(corners) < 3: + return None + area = 0.0 + for index, point in enumerate(corners): + next_point = corners[(index + 1) % len(corners)] + area += point[0] * next_point[1] - next_point[0] * point[1] + return abs(area) / 2.0 + + +def _import_cv2() -> Any: + try: + import cv2 + except ModuleNotFoundError as exc: + raise RuntimeError( + "OpenCV aruco support is not installed. Install the optional vision extra: " + "uv run --extra vision ..." + ) from exc + if not hasattr(cv2, "aruco"): + raise RuntimeError("Installed OpenCV package does not include cv2.aruco") + return cv2 diff --git a/dimos/experimental/dogops/gemini_vision.py b/dimos/experimental/dogops/gemini_vision.py new file mode 100644 index 0000000000..a210d20d04 --- /dev/null +++ b/dimos/experimental/dogops/gemini_vision.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import base64 +import json +import os +from pathlib import Path +from typing import Any, Literal +from urllib import error, request + +from pydantic import Field, ValidationError, field_validator + +from dimos.experimental.dogops.models import DogOpsModel + + +GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" +SUPPORTED_INLINE_IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"} + + +class GeminiImageInspection(DogOpsModel): + schema_version: int = 1 + ok: bool + summary: str + current_description: str + baseline_description: str | None = None + changed: bool + change_summary: str + change_type: Literal[ + "no_change", + "object_added", + "object_removed", + "moved", + "damaged", + "blocked", + "unclear", + "other", + ] + severity: Literal["info", "p3", "p2", "p1"] + confidence: float + observations: list[str] = Field(default_factory=list) + possible_incident: bool = False + recommended_action: str = "" + compared_evidence_ids: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + + @field_validator("confidence") + @classmethod + def confidence_in_range(cls, value: float) -> float: + result = float(value) + if result < 0.0: + return 0.0 + if result > 1.0: + return 1.0 + return result + + +class GeminiVisionResult(DogOpsModel): + ok: bool + status: Literal[ + "completed", + "gemini_unavailable", + "image_missing", + "unsupported_mime_type", + "request_too_large", + "api_error", + "invalid_response", + ] + message: str + model: str + inspection: GeminiImageInspection | None = None + raw_response: dict[str, Any] | None = None + + +def gemini_image_inspection_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "schema_version": {"type": "integer"}, + "ok": {"type": "boolean"}, + "summary": {"type": "string"}, + "current_description": {"type": "string"}, + "baseline_description": {"type": ["string", "null"]}, + "changed": {"type": "boolean"}, + "change_summary": {"type": "string"}, + "change_type": { + "type": "string", + "enum": [ + "no_change", + "object_added", + "object_removed", + "moved", + "damaged", + "blocked", + "unclear", + "other", + ], + }, + "severity": {"type": "string", "enum": ["info", "p3", "p2", "p1"]}, + "confidence": {"type": "number"}, + "observations": {"type": "array", "items": {"type": "string"}}, + "possible_incident": {"type": "boolean"}, + "recommended_action": {"type": "string"}, + "compared_evidence_ids": {"type": "array", "items": {"type": "string"}}, + "limitations": {"type": "array", "items": {"type": "string"}}, + }, + "required": [ + "ok", + "summary", + "current_description", + "changed", + "change_summary", + "change_type", + "severity", + "confidence", + "observations", + "possible_incident", + "recommended_action", + "limitations", + ], + } + + +def inspect_images_with_gemini( + *, + current_image_path: str | Path, + current_mime_type: str, + current_evidence_id: str, + prompt: str, + model: str = "gemini-2.5-flash", + baseline_image_path: str | Path | None = None, + baseline_mime_type: str | None = None, + baseline_evidence_id: str | None = None, + max_image_bytes_inline: int = 20_000_000, + route_context: dict[str, Any] | None = None, + api_key: str | None = None, + timeout_s: float = 30.0, +) -> GeminiVisionResult: + api_key = api_key if api_key is not None else os.environ.get("GEMINI_API_KEY") + if not api_key: + return GeminiVisionResult( + ok=False, + status="gemini_unavailable", + message="GEMINI_API_KEY is not configured", + model=model, + ) + + current_path = Path(current_image_path) + if not current_path.exists() or not current_path.is_file(): + return GeminiVisionResult( + ok=False, + status="image_missing", + message="current image evidence file is missing", + model=model, + ) + if current_mime_type not in SUPPORTED_INLINE_IMAGE_MIME_TYPES: + return GeminiVisionResult( + ok=False, + status="unsupported_mime_type", + message=f"unsupported current image MIME type: {current_mime_type}", + model=model, + ) + + image_parts: list[dict[str, Any]] = [] + try: + image_parts.append(_inline_image_part(current_path, current_mime_type, max_image_bytes_inline)) + if baseline_image_path is not None: + baseline_path = Path(baseline_image_path) + if not baseline_path.exists() or not baseline_path.is_file(): + return GeminiVisionResult( + ok=False, + status="image_missing", + message="baseline image evidence file is missing", + model=model, + ) + baseline_type = baseline_mime_type or "application/octet-stream" + if baseline_type not in SUPPORTED_INLINE_IMAGE_MIME_TYPES: + return GeminiVisionResult( + ok=False, + status="unsupported_mime_type", + message=f"unsupported baseline image MIME type: {baseline_type}", + model=model, + ) + image_parts.append(_inline_image_part(baseline_path, baseline_type, max_image_bytes_inline)) + except ValueError as exc: + return GeminiVisionResult( + ok=False, + status="request_too_large", + message=str(exc), + model=model, + ) + + request_payload = { + "contents": [ + { + "parts": [ + { + "text": _prompt_text( + prompt=prompt, + has_baseline=baseline_image_path is not None, + route_context=route_context or {}, + ) + }, + *image_parts, + ] + } + ], + "generationConfig": { + "responseMimeType": "application/json", + "responseJsonSchema": gemini_image_inspection_schema(), + }, + } + http_request = request.Request( + GEMINI_API_URL.format(model=model), + data=json.dumps(request_payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "x-goog-api-key": api_key, + }, + method="POST", + ) + try: + with request.urlopen(http_request, timeout=timeout_s) as response: + raw = json.loads(response.read().decode("utf-8")) + except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError) as exc: + return GeminiVisionResult( + ok=False, + status="api_error", + message=f"Gemini request failed: {exc.__class__.__name__}", + model=model, + ) + + try: + text = raw["candidates"][0]["content"]["parts"][0]["text"] + parsed = json.loads(text) + parsed["compared_evidence_ids"] = [ + item + for item in [current_evidence_id, baseline_evidence_id] + if item + ] + inspection = GeminiImageInspection.model_validate(parsed) + except (KeyError, IndexError, TypeError, json.JSONDecodeError, ValidationError) as exc: + return GeminiVisionResult( + ok=False, + status="invalid_response", + message=f"Gemini returned invalid structured output: {exc.__class__.__name__}", + model=model, + raw_response=raw if isinstance(raw, dict) else None, + ) + + return GeminiVisionResult( + ok=True, + status="completed", + message=inspection.summary, + model=model, + inspection=inspection, + raw_response=raw, + ) + + +def _inline_image_part(path: Path, mime_type: str, max_image_bytes_inline: int) -> dict[str, Any]: + size = path.stat().st_size + if size > max_image_bytes_inline: + raise ValueError(f"image evidence exceeds inline Gemini limit: {size} bytes") + return { + "inlineData": { + "mimeType": mime_type, + "data": base64.b64encode(path.read_bytes()).decode("ascii"), + } + } + + +def _prompt_text(*, prompt: str, has_baseline: bool, route_context: dict[str, Any]) -> str: + comparison_instruction = ( + "Compare the current image with the baseline image from the same waypoint." + if has_baseline + else "Inspect only the current image because no baseline was available." + ) + return "\n".join( + [ + prompt, + comparison_instruction, + "Be conservative. Do not infer identities or people.", + "Do not claim a change unless visible evidence supports it.", + "Use facility, asset, package, and work-order language.", + "Return only JSON matching the provided schema.", + f"Route context JSON: {json.dumps(route_context, sort_keys=True)}", + ] + ) diff --git a/dimos/experimental/dogops/heatmap_runs.py b/dimos/experimental/dogops/heatmap_runs.py new file mode 100644 index 0000000000..b934aaf0aa --- /dev/null +++ b/dimos/experimental/dogops/heatmap_runs.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +from pathlib import Path +from collections.abc import Callable +import time +from types import SimpleNamespace +from typing import Any, Literal + +from pydantic import Field + +from dimos.experimental.dogops.models import DogOpsModel +from dimos.experimental.dogops.route_run_store import RouteRunStore, new_route_run_id + + +HEATMAP_RUN_ID = "GATHER_HEATMAP" +HEATMAP_RUN_LABEL = "Gather Heatmap" +HEATMAP_DIRNAME = "heatmaps" +LATEST_HEATMAP_FILENAME = "latest_heatmap.json" +MAX_GATHER_DURATION_S = 30.0 +DEFAULT_SAMPLE_INTERVAL_S = 1.0 + + +class HeatmapRunEvent(DogOpsModel): + id: str + ts: float + kind: str = "heatmap" + state: str + waypoint_id: str | None = None + action_id: str | None = None + target_id: str | None = None + x: float | None = None + y: float | None = None + error_m: float | None = None + retries: int = 0 + guided: bool = False + payload: dict[str, Any] = Field(default_factory=dict) + note: str = "" + + +class HeatmapRunState(DogOpsModel): + run_id: str + route_run_id: str + route_id: str = HEATMAP_RUN_ID + state: Literal["running", "completed", "failed"] + started_at: float + completed_at: float | None = None + frame: str = "map" + transport: str = "dimos_costmap_snapshot" + active_waypoint_id: str | None = None + active_action_id: str | None = None + waypoints_total: int = 0 + waypoints_reached: int = 0 + last_error: str | None = None + reach_radius_m: float = 0.0 + waypoint_timeout_s: float = 0.0 + max_retries: int = 0 + events: list[HeatmapRunEvent] = Field(default_factory=list) + + +def gather_heatmap_run( + run_dir: str | Path, + *, + live_snapshot: dict[str, Any], + live_snapshot_reader: Callable[[], dict[str, Any]] | None = None, + area_id: str = "", + duration_s: float = 0.0, + sample_interval_s: float = DEFAULT_SAMPLE_INTERVAL_S, + sleep_fn: Callable[[float], None] = time.sleep, + now: float | None = None, +) -> dict[str, Any]: + root = Path(run_dir) + started_at = now or time.time() + route_run_id = new_route_run_id(HEATMAP_RUN_ID, now=started_at) + duration_s = min(max(0.0, float(duration_s or 0.0)), MAX_GATHER_DURATION_S) + snapshots = _sample_live_snapshots( + live_snapshot, + live_snapshot_reader=live_snapshot_reader, + duration_s=duration_s, + sample_interval_s=sample_interval_s, + sleep_fn=sleep_fn, + ) + live_snapshot = _merge_live_snapshots(snapshots) + costmap = live_snapshot.get("costmap") if isinstance(live_snapshot, dict) else None + cells = costmap.get("cells") if isinstance(costmap, dict) else None + has_costmap = isinstance(cells, list) and bool(cells) + state = HeatmapRunState( + run_id=root.name, + route_run_id=route_run_id, + state="running", + started_at=started_at, + frame=str((costmap or {}).get("frame") or "map"), + transport="dimos_costmap_snapshot", + events=[ + HeatmapRunEvent( + id="heatmap-started", + ts=started_at, + state="started", + target_id=area_id or None, + payload={"area_id": area_id, "duration_s": duration_s}, + note="Gather heatmap run started", + ) + ], + ) + route = SimpleNamespace( + id=HEATMAP_RUN_ID, + label=HEATMAP_RUN_LABEL, + mission_id="gather_heatmap", + ) + route_snapshot = { + "id": HEATMAP_RUN_ID, + "label": HEATMAP_RUN_LABEL, + "mission_id": "gather_heatmap", + "run_kind": "gather_heatmap", + "area_id": area_id, + "duration_s": duration_s, + "waypoints": [], + } + store = RouteRunStore(root) + store.create_route_run( + route_run_id=route_run_id, + dogops_run_id=root.name, + route=route, + state=state, + dry_run=False, + route_snapshot=route_snapshot, + ) + + completed_at = time.time() + state.completed_at = completed_at + if has_costmap: + snapshot = _heatmap_snapshot_payload( + route_run_id=route_run_id, + area_id=area_id, + duration_s=duration_s, + live_snapshot=live_snapshot, + costmap=costmap, + collected_at=completed_at, + ) + snapshot_path = _write_heatmap_snapshot(root, route_run_id, snapshot) + state.state = "completed" + state.events.append( + HeatmapRunEvent( + id="heatmap-collected", + ts=completed_at, + state="completed", + target_id=area_id or None, + payload={ + "area_id": area_id, + "cells": len(cells), + "path": str(snapshot_path), + "source": costmap.get("source") or live_snapshot.get("source"), + }, + note=f"Gathered heatmap with {len(cells)} cells", + ) + ) + store.sync_execution_state(state) + evidence = store.record_evidence( + route_run_id=route_run_id, + event_id=f"{route_run_id}-heatmap-collected", + observation_id=None, + kind="costmap_snapshot", + path=snapshot_path, + mime_type="application/json", + metadata={ + "area_id": area_id, + "cells": len(cells), + "source": costmap.get("source") or live_snapshot.get("source"), + }, + ) + return { + "ok": True, + "run_kind": "gather_heatmap", + "route_run_id": route_run_id, + "state": state.model_dump(mode="json"), + "heatmap": snapshot, + "snapshot_path": str(snapshot_path), + "evidence": evidence, + } + + state.state = "failed" + state.last_error = "No live DimOS costmap cells are available to gather." + state.events.append( + HeatmapRunEvent( + id="heatmap-failed", + ts=completed_at, + state="failed", + target_id=area_id or None, + payload={"area_id": area_id, "live_status": live_snapshot.get("status")}, + note=state.last_error, + ) + ) + store.sync_execution_state(state) + return { + "ok": False, + "run_kind": "gather_heatmap", + "error": "heatmap_unavailable", + "message": state.last_error, + "route_run_id": route_run_id, + "state": state.model_dump(mode="json"), + } + + +def latest_heatmap_snapshot(run_dir: str | Path) -> dict[str, Any] | None: + path = Path(run_dir) / HEATMAP_DIRNAME / LATEST_HEATMAP_FILENAME + return _read_heatmap_snapshot(path) + + +def heatmap_snapshot_for_route_run( + run_dir: str | Path, + route_run_id: str, + *, + evidence: list[dict[str, Any]] | None = None, +) -> dict[str, Any] | None: + root = Path(run_dir) + candidates = [ + Path(str(item.get("path"))) + for item in evidence or [] + if item.get("kind") == "costmap_snapshot" and item.get("path") + ] + candidates.append(root / HEATMAP_DIRNAME / f"{route_run_id}.json") + for path in candidates: + snapshot = _read_heatmap_snapshot(path) + if snapshot is not None: + return snapshot + return None + + +def _read_heatmap_snapshot(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _write_heatmap_snapshot( + run_dir: Path, + route_run_id: str, + snapshot: dict[str, Any], +) -> Path: + heatmap_dir = run_dir / HEATMAP_DIRNAME + heatmap_dir.mkdir(parents=True, exist_ok=True) + snapshot_path = heatmap_dir / f"{route_run_id}.json" + raw = json.dumps(snapshot, indent=2, sort_keys=True) + "\n" + snapshot_path.write_text(raw, encoding="utf-8") + (heatmap_dir / LATEST_HEATMAP_FILENAME).write_text(raw, encoding="utf-8") + return snapshot_path + + +def _heatmap_snapshot_payload( + *, + route_run_id: str, + area_id: str, + duration_s: float, + live_snapshot: dict[str, Any], + costmap: dict[str, Any], + collected_at: float, +) -> dict[str, Any]: + return { + "schema_version": 1, + "run_kind": "gather_heatmap", + "route_run_id": route_run_id, + "area_id": area_id, + "duration_s": duration_s, + "collected_at": collected_at, + "source": costmap.get("source") or live_snapshot.get("source") or "DimOS live costmap", + "status": live_snapshot.get("status"), + "robot_pose": live_snapshot.get("robot_pose"), + "target": live_snapshot.get("target"), + "costmap": costmap, + } + + +def _sample_live_snapshots( + initial_snapshot: dict[str, Any], + *, + live_snapshot_reader: Callable[[], dict[str, Any]] | None, + duration_s: float, + sample_interval_s: float, + sleep_fn: Callable[[float], None], +) -> list[dict[str, Any]]: + snapshots = [initial_snapshot] + if live_snapshot_reader is None or duration_s <= 0: + return snapshots + interval = max(0.1, float(sample_interval_s or DEFAULT_SAMPLE_INTERVAL_S)) + deadline = time.time() + duration_s + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + sleep_fn(min(interval, remaining)) + try: + snapshot = live_snapshot_reader() + except Exception: + continue + if isinstance(snapshot, dict): + snapshots.append(snapshot) + return snapshots + + +def _merge_live_snapshots(snapshots: list[dict[str, Any]]) -> dict[str, Any]: + if not snapshots: + return {} + latest = dict(snapshots[-1]) + costmaps = [ + snapshot.get("costmap") + for snapshot in snapshots + if isinstance(snapshot.get("costmap"), dict) + ] + if not costmaps: + return latest + cells_by_key: dict[tuple[float, float, float, float], dict[str, Any]] = {} + for costmap in costmaps: + for cell in costmap.get("cells") or []: + if not isinstance(cell, dict): + continue + key = ( + round(float(cell.get("x") or 0.0), 4), + round(float(cell.get("y") or 0.0), 4), + round(float(cell.get("width") or 0.0), 4), + round(float(cell.get("height") or 0.0), 4), + ) + previous = cells_by_key.get(key) + if previous is None or float(cell.get("cost") or 0.0) > float(previous.get("cost") or 0.0): + cells_by_key[key] = dict(cell) + merged_costmap = dict(costmaps[-1]) + merged_costmap["cells"] = list(cells_by_key.values()) + latest["costmap"] = merged_costmap + latest["samples"] = len(snapshots) + return latest diff --git a/dimos/experimental/dogops/live_camera.py b/dimos/experimental/dogops/live_camera.py new file mode 100644 index 0000000000..95bcb00e43 --- /dev/null +++ b/dimos/experimental/dogops/live_camera.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import base64 +import os +from pathlib import Path +import sys +import threading +import time +from typing import Any + + +DEFAULT_DIMOS_ROOT = os.environ.get("DIMOS_ROOT", "") +DEFAULT_COLOR_IMAGE_TOPIC = os.environ.get("DOGOPS_CAMERA_TOPIC", "/color_image") +LIVE_CAMERA_MAX_AGE_S = 5.0 + + +class DogOpsLiveCameraAdapter: + """Best-effort bridge from the DimOS color_image topic into dashboard JPEGs.""" + + def __init__(self, *, topic: str = DEFAULT_COLOR_IMAGE_TOPIC) -> None: + self.topic = topic + self._lock = threading.RLock() + self._started = False + self._error = "" + self._unsubscribe: Any | None = None + self._transport: Any | None = None + self._latest: tuple[float, Any] | None = None + + def status(self) -> dict[str, Any]: + self.start() + with self._lock: + latest = self._latest + error = self._error + now = time.time() + age_s = now - latest[0] if latest is not None else None + fresh = age_s is not None and age_s <= LIVE_CAMERA_MAX_AGE_S + frame = latest[1] if fresh else None + return { + "ok": fresh, + "source": "DimOS color_image", + "topic": self.topic, + "status": "receiving" if fresh else "stale_frame" if latest is not None else "waiting_for_frame", + "error": error, + "received": fresh, + "stale": latest is not None and not fresh, + "age_s": round(age_s, 3) if age_s is not None else None, + "width": int(getattr(frame, "width", 0) or 0) if frame is not None else None, + "height": int(getattr(frame, "height", 0) or 0) if frame is not None else None, + "format": _format_name(getattr(frame, "format", None)) if frame is not None else None, + "frame_id": str(getattr(frame, "frame_id", "") or "") if frame is not None else "", + } + + def frame_jpeg(self, *, quality: int = 75) -> bytes | None: + self.start() + with self._lock: + latest = self._latest + if latest is None or time.time() - latest[0] > LIVE_CAMERA_MAX_AGE_S: + return None + frame = latest[1] + if hasattr(frame, "to_base64"): + return base64.b64decode(frame.to_base64(quality=quality)) + raise TypeError(f"latest color_image frame is not a DimOS Image: {type(frame)!r}") + + def start(self) -> None: + with self._lock: + if self._started: + return + self._started = True + try: + JpegLcmTransport, Image = _import_dimos_camera_types() + transport = JpegLcmTransport(self.topic, Image) + unsubscribe = transport.subscribe(self._record) + except Exception as exc: + with self._lock: + self._error = ( + "DimOS camera imports or subscription unavailable in this Python environment. " + f"Run the dashboard from the full DimOS checkout/env or set DOGOPS_CAMERA_TOPIC. {exc}" + ) + return + + with self._lock: + self._transport = transport + self._unsubscribe = unsubscribe + + def _record(self, msg: Any) -> None: + with self._lock: + self._latest = (time.time(), msg) + self._error = "" + + def stop(self) -> None: + with self._lock: + unsubscribe = self._unsubscribe + transport = self._transport + self._unsubscribe = None + self._transport = None + self._latest = None + self._started = False + if unsubscribe is not None: + try: + unsubscribe() + except Exception: + pass + if transport is not None: + try: + transport.stop() + except Exception: + pass + + +def _import_dimos_camera_types() -> tuple[Any, Any]: + try: + from dimos.core.transport import JpegLcmTransport + from dimos.msgs.sensor_msgs.Image import Image + except ModuleNotFoundError: + _extend_dimos_package_path() + from dimos.core.transport import JpegLcmTransport + from dimos.msgs.sensor_msgs.Image import Image + return JpegLcmTransport, Image + + +def _extend_dimos_package_path() -> None: + if not DEFAULT_DIMOS_ROOT: + return + dimos_root = Path(DEFAULT_DIMOS_ROOT).expanduser() + package_root = dimos_root / "dimos" + if not package_root.exists(): + return + if str(dimos_root) not in sys.path: + sys.path.append(str(dimos_root)) + import dimos + + package_path = getattr(dimos, "__path__", None) + if package_path is not None and str(package_root) not in package_path: + package_path.append(str(package_root)) + + +def _format_name(value: Any) -> str: + return str(getattr(value, "value", value) or "") diff --git a/dimos/experimental/dogops/live_map.py b/dimos/experimental/dogops/live_map.py new file mode 100644 index 0000000000..cbaa189b11 --- /dev/null +++ b/dimos/experimental/dogops/live_map.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import math +import os +from pathlib import Path +import sys +import threading +import time +from typing import Any + + +LIVE_TOPICS = { + "global_costmap": "/global_costmap", + "navigation_costmap": "/navigation_costmap", + "odom": "/odom", + "path": "/path", + "target": "/target", + "goal_request": "/goal_request", + "clicked_point": "/clicked_point", +} +LIVE_TOPIC_MAX_AGE_S = 5.0 + + +class DogOpsLiveMapAdapter: + """Bridge DimOS navigation topics into the DogOps mission-map payload.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._started = False + self._error = "" + self._unsubscribers: list[Any] = [] + self._transports: list[Any] = [] + self._latest: dict[str, tuple[float, Any]] = {} + + def snapshot(self) -> dict[str, Any]: + self.start() + with self._lock: + recorded = dict(self._latest) + error = self._error + now = time.time() + latest = { + name: item + for name, item in recorded.items() + if now - item[0] <= LIVE_TOPIC_MAX_AGE_S + } + topics = { + name: { + "topic": topic, + "received": name in latest, + "age_s": round(now - recorded[name][0], 3) if name in recorded else None, + "stale": name in recorded and name not in latest, + } + for name, topic in LIVE_TOPICS.items() + } + costmap_msg = _latest_first(latest, "navigation_costmap", "global_costmap") + path_msg = _latest_value(latest, "path") + odom_msg = _latest_value(latest, "odom") + target_msg = _latest_first(latest, "target", "goal_request", "clicked_point") + path = _path_to_points(path_msg) if path_msg is not None else [] + ok = any(item["received"] for item in topics.values()) + return { + "ok": ok, + "source": "DimOS live LCM topics", + "status": "receiving" if ok else "waiting_for_topics", + "error": error, + "topics": topics, + "costmap": _grid_to_costmap(costmap_msg) if costmap_msg is not None else None, + "path": path, + "route": _path_to_route(path), + "robot_pose": _pose_to_map_pose(odom_msg, source="odom") if odom_msg is not None else None, + "target": _pose_to_map_pose(target_msg, source="target") if target_msg is not None else None, + } + + def start(self) -> None: + with self._lock: + if self._started: + return + self._started = True + try: + LCMTransport, OccupancyGrid, Path, PoseStamped, PointStamped = _import_dimos_topic_types() + except Exception as exc: + with self._lock: + self._error = ( + "DimOS topic imports unavailable in this Python environment. " + f"Run from the full DimOS checkout/env or install its deps. {exc}" + ) + return + + specs = { + "global_costmap": (LIVE_TOPICS["global_costmap"], OccupancyGrid), + "navigation_costmap": (LIVE_TOPICS["navigation_costmap"], OccupancyGrid), + "odom": (LIVE_TOPICS["odom"], PoseStamped), + "path": (LIVE_TOPICS["path"], Path), + "target": (LIVE_TOPICS["target"], PoseStamped), + "goal_request": (LIVE_TOPICS["goal_request"], PoseStamped), + "clicked_point": (LIVE_TOPICS["clicked_point"], PointStamped), + } + for name, (topic, msg_type) in specs.items(): + try: + transport = LCMTransport(topic, msg_type) + unsubscribe = transport.subscribe(lambda msg, item=name: self._record(item, msg)) + self._unsubscribers.append(unsubscribe) + self._transports.append(transport) + except Exception as exc: + with self._lock: + self._error = f"Failed subscribing to {topic}: {exc}" + + def stop(self) -> None: + with self._lock: + unsubscribers = list(self._unsubscribers) + transports = list(self._transports) + self._unsubscribers.clear() + self._transports.clear() + self._latest.clear() + self._started = False + for unsubscribe in unsubscribers: + try: + unsubscribe() + except Exception: + pass + for transport in transports: + try: + transport.stop() + except Exception: + pass + + def _record(self, name: str, msg: Any) -> None: + with self._lock: + self._latest[name] = (time.time(), msg) + + +def _import_dimos_topic_types() -> tuple[Any, Any, Any, Any, Any]: + try: + from dimos.core.transport import LCMTransport + from dimos.msgs.geometry_msgs.PointStamped import PointStamped + from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped + from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid + from dimos.msgs.nav_msgs.Path import Path + except ModuleNotFoundError: + _extend_dimos_package_path() + from dimos.core.transport import LCMTransport + from dimos.msgs.geometry_msgs.PointStamped import PointStamped + from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped + from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid + from dimos.msgs.nav_msgs.Path import Path + return LCMTransport, OccupancyGrid, Path, PoseStamped, PointStamped + + +def _extend_dimos_package_path() -> None: + root = os.environ.get("DIMOS_ROOT") + if not root: + return + dimos_root = Path(root).expanduser() + package_root = dimos_root / "dimos" + if not package_root.exists(): + return + if str(dimos_root) not in sys.path: + sys.path.append(str(dimos_root)) + import dimos + + package_path = getattr(dimos, "__path__", None) + if package_path is not None and str(package_root) not in package_path: + package_path.append(str(package_root)) + + +def _latest_value(latest: dict[str, tuple[float, Any]], name: str) -> Any | None: + item = latest.get(name) + return item[1] if item is not None else None + + +def _latest_first(latest: dict[str, tuple[float, Any]], *names: str) -> Any | None: + available = [latest[name] for name in names if name in latest] + if not available: + return None + return max(available, key=lambda item: item[0])[1] + + +def _grid_to_costmap(msg: Any, *, max_columns: int = 48, max_rows: int = 32) -> dict[str, Any]: + width = int(getattr(msg, "width", 0) or getattr(getattr(msg, "info", None), "width", 0) or 0) + height = int(getattr(msg, "height", 0) or getattr(getattr(msg, "info", None), "height", 0) or 0) + resolution = float( + getattr(msg, "resolution", 0.05) + or getattr(getattr(msg, "info", None), "resolution", 0.05) + or 0.05 + ) + origin = getattr(getattr(msg, "info", None), "origin", None) or getattr(msg, "origin", None) + origin_x = float(getattr(getattr(origin, "position", None), "x", 0.0) or 0.0) + origin_y = float(getattr(getattr(origin, "position", None), "y", 0.0) or 0.0) + grid = getattr(msg, "grid", None) + columns = min(max_columns, width) if width > 0 else 0 + rows = min(max_rows, height) if height > 0 else 0 + if grid is None or width <= 0 or height <= 0 or columns <= 0 or rows <= 0: + return {"source": "DimOS live costmap", "columns": 0, "rows": 0, "cells": []} + + cells: list[dict[str, float]] = [] + for row in range(rows): + y0 = math.floor(row * height / rows) + y1 = math.floor((row + 1) * height / rows) + for column in range(columns): + x0 = math.floor(column * width / columns) + x1 = math.floor((column + 1) * width / columns) + cells.append( + { + "x": origin_x + x0 * resolution, + "y": origin_y + y0 * resolution, + "width": (x1 - x0) * resolution, + "height": (y1 - y0) * resolution, + "cost": _block_cost(grid, x0, x1, y0, y1), + } + ) + return { + "source": "DimOS live costmap", + "columns": columns, + "rows": rows, + "resolution_m": resolution, + "cells": cells, + } + + +def _block_cost(grid: Any, x0: int, x1: int, y0: int, y1: int) -> float: + best = 0.0 + for y in range(y0, y1): + for x in range(x0, x1): + try: + value = float(grid[y][x]) + except Exception: + value = -1.0 + if value < 0: + continue + best = max(best, min(1.0, value / 100.0)) + return best + + +def _path_to_points(msg: Any) -> list[dict[str, Any]]: + points: list[dict[str, Any]] = [] + for pose in getattr(msg, "poses", []) or []: + point = _pose_to_map_pose(pose, source="path") + if point is not None: + points.append(point) + return points + + +def _path_to_route(path: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "target_id": f"LIVE-PATH-{index + 1:03d}", + "x": point["x"], + "y": point["y"], + "success": True, + "guided": False, + "retries": 0, + "note": "DimOS planner path", + } + for index, point in enumerate(path) + ] + + +def _pose_to_map_pose(msg: Any, *, source: str) -> dict[str, Any] | None: + if msg is None: + return None + x = getattr(msg, "x", None) + y = getattr(msg, "y", None) + if x is None or y is None: + position = getattr(msg, "position", None) + x = getattr(position, "x", None) + y = getattr(position, "y", None) + if x is None or y is None: + return None + yaw = getattr(msg, "yaw", None) + return { + "x": float(x), + "y": float(y), + "theta_deg": math.degrees(float(yaw)) if yaw is not None else None, + "source": source, + } diff --git a/dimos/experimental/dogops/map_authoring.py b/dimos/experimental/dogops/map_authoring.py new file mode 100644 index 0000000000..df00984c57 --- /dev/null +++ b/dimos/experimental/dogops/map_authoring.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import json +import math +import os +import subprocess +import threading +import time +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import Field, ValidationError, field_validator, model_validator + +from dimos.experimental.dogops.models import DogOpsModel +from dimos.experimental.dogops.route_actions import EditableRouteAction + + +AUTHORING_FILENAME = "map_authoring.json" +AUTHORING_SCHEMA_VERSION = 1 + + +class EditableMapPoint(DogOpsModel): + x: float + y: float + theta_deg: float | None = None + source: Literal[ + "site_config", + "dashboard_edit", + "observation", + "live_topic", + "qr_cargo_event", + ] = "dashboard_edit" + + @field_validator("x", "y", "theta_deg") + @classmethod + def finite_coordinate(cls, value: float | None) -> float | None: + if value is None: + return None + result = float(value) + if not math.isfinite(result): + raise ValueError("coordinate must be finite") + return result + + +class EditableMapEntity(DogOpsModel): + id: str + kind: Literal["zone", "asset", "package", "checkpoint"] + label: str + pose: EditableMapPoint + tag_id: int | None = None + zone_id: str | None = None + source_id: str | None = None + + @field_validator("id", "label") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("value must not be empty") + return result + + +class EditableNoGoShape(DogOpsModel): + id: str + label: str + shape: Literal["rectangle", "polygon"] = "rectangle" + points: list[EditableMapPoint] + enabled: bool = True + dimos_constraint_status: Literal["not_supported", "pending", "published", "failed"] = ( + "not_supported" + ) + + @field_validator("id", "label") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("value must not be empty") + return result + + @model_validator(mode="after") + def validate_points(self) -> "EditableNoGoShape": + minimum = 2 if self.shape == "rectangle" else 3 + if len(self.points) < minimum: + raise ValueError(f"{self.shape} requires at least {minimum} points") + return self + + +class EditableRouteWaypoint(DogOpsModel): + id: str + label: str + pose: EditableMapPoint + target_id: str | None = None + required: bool = True + actions: list[EditableRouteAction] = Field(default_factory=list) + + @field_validator("id", "label") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("value must not be empty") + return result + + +class EditableRoute(DogOpsModel): + id: str + label: str + waypoints: list[EditableRouteWaypoint] = Field(default_factory=list) + mission_id: str | None = None + + @field_validator("id", "label") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("value must not be empty") + return result + + @model_validator(mode="after") + def validate_waypoint_ids(self) -> "EditableRoute": + _require_unique([waypoint.id for waypoint in self.waypoints], "route waypoint id") + return self + + +class EditableIncidentLocation(DogOpsModel): + incident_id: str + entity_id: str | None = None + pose: EditableMapPoint + evidence_observation_ids: list[str] = Field(default_factory=list) + + @field_validator("incident_id") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("incident_id must not be empty") + return result + + +class EditableTagBinding(DogOpsModel): + tag_id: int + entity_id: str + label: str + binding_kind: Literal["zone", "asset", "package", "checkpoint"] + + @field_validator("entity_id", "label") + @classmethod + def required_text(cls, value: str) -> str: + result = str(value).strip() + if not result: + raise ValueError("value must not be empty") + return result + + +class MapAuthoringState(DogOpsModel): + schema_version: int = AUTHORING_SCHEMA_VERSION + site_id: str = "" + frame: str = "world" + updated_at: float = Field(default_factory=time.time) + home: EditableMapPoint | None = None + selected_route_id: str | None = None + entities: list[EditableMapEntity] = Field(default_factory=list) + no_go_shapes: list[EditableNoGoShape] = Field(default_factory=list) + routes: list[EditableRoute] = Field(default_factory=list) + incident_locations: list[EditableIncidentLocation] = Field(default_factory=list) + tag_bindings: list[EditableTagBinding] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_unique_keys(self) -> "MapAuthoringState": + _require_unique([entity.id for entity in self.entities], "entity id") + _require_unique([shape.id for shape in self.no_go_shapes], "no-go shape id") + _require_unique([route.id for route in self.routes], "route id") + _require_unique( + [location.incident_id for location in self.incident_locations], + "incident id", + ) + _require_unique([binding.tag_id for binding in self.tag_bindings], "tag id") + if self.selected_route_id and self.selected_route_id not in { + route.id for route in self.routes + }: + raise ValueError(f"unknown selected route id: {self.selected_route_id}") + return self + + def touch(self) -> "MapAuthoringState": + self.updated_at = time.time() + return self + + +def authoring_path(run_dir: str | Path) -> Path: + return Path(run_dir) / AUTHORING_FILENAME + + +def default_authoring(site_id: str = "") -> MapAuthoringState: + return MapAuthoringState(site_id=site_id) + + +def load_map_authoring(run_dir: str | Path, *, site_id: str = "") -> MapAuthoringState: + path = authoring_path(run_dir) + if not path.exists(): + return default_authoring(site_id=site_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid map authoring JSON: {path}") from exc + if not isinstance(payload, dict): + raise ValueError(f"map authoring file must contain an object: {path}") + if site_id and not payload.get("site_id"): + payload["site_id"] = site_id + return MapAuthoringState.model_validate(payload) + + +def save_map_authoring(run_dir: str | Path, authoring: MapAuthoringState) -> Path: + path = authoring_path(run_dir) + path.parent.mkdir(parents=True, exist_ok=True) + authoring.touch() + raw = json.dumps(authoring.model_dump(mode="json"), indent=2, sort_keys=True) + tmp_path = path.with_name( + f"{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp" + ) + tmp_path.write_text(raw + "\n", encoding="utf-8") + tmp_path.replace(path) + return path + + +def replace_entity( + authoring: MapAuthoringState, + entity: EditableMapEntity, +) -> MapAuthoringState: + authoring.entities = [ + existing for existing in authoring.entities if existing.id != entity.id + ] + authoring.entities.append(entity) + return _validated(authoring) + + +def delete_entity(authoring: MapAuthoringState, entity_id: str) -> MapAuthoringState: + authoring.entities = [entity for entity in authoring.entities if entity.id != entity_id] + return _validated(authoring) + + +def replace_no_go_shape( + authoring: MapAuthoringState, + shape: EditableNoGoShape, +) -> MapAuthoringState: + authoring.no_go_shapes = [ + existing for existing in authoring.no_go_shapes if existing.id != shape.id + ] + authoring.no_go_shapes.append(shape) + return _validated(authoring) + + +def delete_no_go_shape(authoring: MapAuthoringState, shape_id: str) -> MapAuthoringState: + authoring.no_go_shapes = [ + shape for shape in authoring.no_go_shapes if shape.id != shape_id + ] + return _validated(authoring) + + +def replace_route(authoring: MapAuthoringState, route: EditableRoute) -> MapAuthoringState: + authoring.routes = [existing for existing in authoring.routes if existing.id != route.id] + authoring.routes.append(route) + if authoring.selected_route_id is None: + authoring.selected_route_id = route.id + return _validated(authoring) + + +def delete_route(authoring: MapAuthoringState, route_id: str) -> MapAuthoringState: + authoring.routes = [route for route in authoring.routes if route.id != route_id] + if authoring.selected_route_id == route_id: + authoring.selected_route_id = authoring.routes[0].id if authoring.routes else None + return _validated(authoring) + + +def select_route(authoring: MapAuthoringState, route_id: str | None) -> MapAuthoringState: + authoring.selected_route_id = route_id + return _validated(authoring) + + +def replace_incident_location( + authoring: MapAuthoringState, + location: EditableIncidentLocation, +) -> MapAuthoringState: + authoring.incident_locations = [ + existing + for existing in authoring.incident_locations + if existing.incident_id != location.incident_id + ] + authoring.incident_locations.append(location) + return _validated(authoring) + + +def replace_tag_binding( + authoring: MapAuthoringState, + binding: EditableTagBinding, +) -> MapAuthoringState: + authoring.tag_bindings = [ + existing for existing in authoring.tag_bindings if existing.tag_id != binding.tag_id + ] + authoring.tag_bindings.append(binding) + return _validated(authoring) + + +def delete_tag_binding(authoring: MapAuthoringState, tag_id: int) -> MapAuthoringState: + authoring.tag_bindings = [ + binding for binding in authoring.tag_bindings if binding.tag_id != tag_id + ] + return _validated(authoring) + + +def export_authoring_yaml( + run_dir: str | Path, + authoring: MapAuthoringState, +) -> dict[str, str]: + export_dir = Path(run_dir) / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + site_path = export_dir / "site_authoring.yaml" + mission_path = export_dir / "mission_authoring.yaml" + payload = authoring.model_dump(mode="json") + site_payload = { + "site_id": authoring.site_id, + "frame": authoring.frame, + "home": payload["home"], + "entities": payload["entities"], + "no_go_shapes": payload["no_go_shapes"], + "tag_bindings": payload["tag_bindings"], + } + mission_payload = { + "site_id": authoring.site_id, + "selected_route_id": authoring.selected_route_id, + "routes": payload["routes"], + "mission_steps": _mission_steps_for_selected_route(authoring), + "incident_locations": payload["incident_locations"], + } + site_path.write_text(yaml.safe_dump(site_payload, sort_keys=False), encoding="utf-8") + mission_path.write_text( + yaml.safe_dump(mission_payload, sort_keys=False), + encoding="utf-8", + ) + return {"site": str(site_path), "mission": str(mission_path)} + + +def publish_no_go_constraints( + authoring: MapAuthoringState, + *, + command: str | None = None, + timeout_s: float = 5.0, +) -> MapAuthoringState: + publisher = command or os.environ.get("DOGOPS_NO_GO_PUBLISH_COMMAND") + enabled_shapes = [shape for shape in authoring.no_go_shapes if shape.enabled] + if not enabled_shapes: + return authoring + if not publisher: + for shape in enabled_shapes: + shape.dimos_constraint_status = "not_supported" + return _validated(authoring) + + payload = json.dumps( + { + "site_id": authoring.site_id, + "frame": authoring.frame, + "no_go_shapes": [ + shape.model_dump(mode="json") for shape in enabled_shapes + ], + }, + separators=(",", ":"), + ) + try: + result = subprocess.run( + publisher, + input=payload, + capture_output=True, + check=False, + shell=True, + text=True, + timeout=timeout_s, + ) + except Exception: + for shape in enabled_shapes: + shape.dimos_constraint_status = "failed" + return _validated(authoring) + + status = "published" if result.returncode == 0 else "failed" + for shape in enabled_shapes: + shape.dimos_constraint_status = status + return _validated(authoring) + + +def validation_error_message(exc: ValidationError | ValueError) -> str: + if isinstance(exc, ValidationError): + errors = exc.errors() + if errors: + return str(errors[0].get("msg") or errors[0]) + return str(exc) + + +def _mission_steps_for_selected_route(authoring: MapAuthoringState) -> list[dict[str, Any]]: + route = next( + (item for item in authoring.routes if item.id == authoring.selected_route_id), + authoring.routes[0] if authoring.routes else None, + ) + if route is None: + return [] + steps: list[dict[str, Any]] = [] + for index, waypoint in enumerate(route.waypoints, 1): + steps.append( + { + "id": waypoint.id, + "action": "goto", + "target_id": waypoint.target_id or waypoint.id, + "required": waypoint.required, + "pose_hint": waypoint.pose.model_dump(mode="json"), + "sequence": index, + } + ) + return steps + + +def _validated(authoring: MapAuthoringState) -> MapAuthoringState: + return MapAuthoringState.model_validate(authoring.model_dump(mode="json")) + + +def _require_unique(values: list[Any], label: str) -> None: + seen: set[Any] = set() + for value in values: + if value in seen: + raise ValueError(f"duplicate {label}: {value}") + seen.add(value) diff --git a/dimos/experimental/dogops/map_snapshot.py b/dimos/experimental/dogops/map_snapshot.py new file mode 100644 index 0000000000..137eda0382 --- /dev/null +++ b/dimos/experimental/dogops/map_snapshot.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import math +from typing import Any + + +DEFAULT_RERUN_URL = ( + "http://127.0.0.1:9878/?url=rerun%2Bhttp%3A%2F%2F127.0.0.1%3A9877%2Fproxy" +) +DEFAULT_DIMOS_ROOT = "/Users/chris/Documents/Workspace/dimos" +DEFAULT_LIVE_ROBOT_IP = "192.168.123.161" + + +def build_map_snapshot( + state: dict[str, Any], + report: dict[str, Any] | None = None, + *, + mode: str = "demo", + live_overlay: dict[str, Any] | None = None, + rerun_url: str | None = None, + demo_rerun_url: str | None = None, + live_rerun_url: str | None = None, + live_robot_ip: str | None = None, +) -> dict[str, Any]: + site = state.get("site") or {} + zones = _zones(site) + zone_by_id = {zone["id"]: zone for zone in zones} + assets = _assets(site, zone_by_id) + entity_by_id = {**zone_by_id, **{asset["id"]: asset for asset in assets}} + packages = _packages(site, state, report or {}, zone_by_id) + observations = _observations(state) + route = _route(state, entity_by_id) + robot_pose = _latest_pose(observations, route, zone_by_id) + target = None + overlay = live_overlay if mode == "live" else None + if overlay: + route = overlay.get("route") or route + robot_pose = overlay.get("robot_pose") or robot_pose + target = overlay.get("target") + points = [ + {"x": item["x"], "y": item["y"]} + for group in (zones, assets, packages, observations) + for item in group + if item.get("x") is not None and item.get("y") is not None + ] + for group in (route, [target] if target else []): + for item in group: + if item and item.get("x") is not None and item.get("y") is not None: + points.append({"x": item["x"], "y": item["y"]}) + if robot_pose: + points.append(robot_pose) + + modes = build_view_modes( + demo_rerun_url=demo_rerun_url or rerun_url, + live_rerun_url=live_rerun_url or rerun_url, + live_robot_ip=live_robot_ip, + ) + bounds = _bounds(points) + costmap = (overlay or {}).get("costmap") or _costmap(bounds, zones, assets, packages, route) + live_status = overlay or { + "ok": False, + "source": "DimOS live LCM topics", + "status": "not_requested" if mode != "live" else "waiting_for_topics", + "topics": {}, + "error": "", + } + return { + "run": state.get("run") or {}, + "site": { + "id": site.get("site_id"), + "name": site.get("site_name"), + "tag_family": site.get("tag_family"), + "marker_length_m": site.get("marker_length_m"), + }, + "bounds": bounds, + "zones": zones, + "assets": assets, + "packages": packages, + "observations": observations, + "route": route, + "robot_pose": robot_pose, + "target": target, + "costmap": costmap, + "streams": { + "semantic_source": "DogOps run state", + "live_source": "DimOS live LCM topics", + "rerun_url": modes["demo"]["rerun_url"], + }, + "mode": mode, + "default_mode": "demo", + "modes": modes, + "live": live_status, + "counts": { + "zones": len(zones), + "assets": len(assets), + "packages": len(packages), + "observations_with_pose": len(observations), + "route_events": len(route), + "costmap_cells": len(costmap.get("cells") or []), + }, + } + + +def build_view_modes( + *, + demo_rerun_url: str | None = None, + live_rerun_url: str | None = None, + live_robot_ip: str | None = None, +) -> dict[str, dict[str, str]]: + robot_ip = live_robot_ip or DEFAULT_LIVE_ROBOT_IP + demo_url = demo_rerun_url or DEFAULT_RERUN_URL + live_url = live_rerun_url or DEFAULT_RERUN_URL + return { + "demo": { + "label": "Demo Replay", + "badge": "Replay", + "rerun_url": demo_url, + "summary": "Replay the full DimOS Go2 stack to prove heatmap, costmap, path, pose, and camera rendering without touching hardware.", + "command": ( + f"cd {DEFAULT_DIMOS_ROOT}\n" + "uv run dimos --replay --rerun-web --rerun-open web " + "run unitree-go2-dogops --daemon" + ), + "control_note": "DogOps controls stay available for UI smoke; robot motion is replay/simulated.", + }, + "live": { + "label": "Live Dog", + "badge": "Live", + "rerun_url": live_url, + "summary": "Use the same DimOS map/costmap/path/camera streams while DogOps sends commands to the real Go2 control path.", + "command": ( + f"cd {DEFAULT_DIMOS_ROOT}\n" + f"uv run dimos --robot-ip {robot_ip} --rerun-web --rerun-open web " + "run unitree-go2-dogops --daemon" + ), + "control_note": "Dashboard controls call DogOps/DimOS endpoints; Rerun is visualization only.", + }, + } + + +def _zones(site: dict[str, Any]) -> list[dict[str, Any]]: + zones: list[dict[str, Any]] = [] + for zone in site.get("zones") or []: + pose = zone.get("pose_hint") or {} + zones.append( + { + "id": zone.get("id"), + "label": zone.get("display_name") or zone.get("id"), + "kind": zone.get("zone_kind"), + "tag_id": zone.get("tag_id"), + "x": _float_or_none(pose.get("x")), + "y": _float_or_none(pose.get("y")), + "theta_deg": _float_or_none(pose.get("theta_deg")), + "radius_m": float(zone.get("radius_m") or 0.8), + "no_go": bool(zone.get("no_go")), + "source": pose.get("source") or "site_config", + } + ) + return zones + + +def _assets(site: dict[str, Any], zone_by_id: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + assets: list[dict[str, Any]] = [] + zone_offsets: dict[str, int] = {} + for asset in site.get("assets") or []: + zone_id = asset.get("zone_id") + zone = zone_by_id.get(zone_id or "") + x, y = _offset_from_zone(zone, zone_offsets, zone_id or "", radius=0.34) + assets.append( + { + "id": asset.get("id"), + "label": asset.get("display_name") or asset.get("id"), + "kind": asset.get("asset_kind"), + "zone_id": zone_id, + "tag_id": asset.get("tag_id"), + "x": x, + "y": y, + "expected_clear": asset.get("expected_clear"), + "expected_status": asset.get("expected_status"), + } + ) + return assets + + +def _packages( + site: dict[str, Any], + state: dict[str, Any], + report: dict[str, Any], + zone_by_id: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + statuses = state.get("package_statuses") or {} + report_packages = {item.get("package_id"): item for item in report.get("packages") or []} + packages: list[dict[str, Any]] = [] + zone_offsets: dict[str, int] = {} + for package in site.get("packages") or []: + package_id = package.get("id") + status = statuses.get(package_id) or report_packages.get(package_id) or {} + expected_zone_id = status.get("expected_zone_id") or package.get("expected_zone_id") + observed_zone_id = status.get("observed_zone_id") + display_zone_id = observed_zone_id or expected_zone_id + zone = zone_by_id.get(display_zone_id or "") + x, y = _offset_from_zone(zone, zone_offsets, display_zone_id or "", radius=0.62) + packages.append( + { + "id": package_id, + "label": package.get("display_name") or package_id, + "tag_id": package.get("tag_id"), + "expected_zone_id": expected_zone_id, + "observed_zone_id": observed_zone_id, + "state": status.get("state") or "expected", + "blocks_asset_id": status.get("blocks_asset_id"), + "x": x, + "y": y, + } + ) + return packages + + +def _observations(state: dict[str, Any]) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + for obs in state.get("observations") or []: + pose = obs.get("pose") or {} + x = _float_or_none(pose.get("x")) + y = _float_or_none(pose.get("y")) + if x is None or y is None: + continue + observations.append( + { + "id": obs.get("id"), + "entity_id": obs.get("entity_id"), + "zone_id": obs.get("zone_id"), + "tag_id": obs.get("tag_id"), + "x": x, + "y": y, + "theta_deg": _float_or_none(pose.get("theta_deg")), + "source": obs.get("source") or pose.get("source") or "observation", + } + ) + return observations + + +def _route(state: dict[str, Any], entity_by_id: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + route: list[dict[str, Any]] = [] + for event in state.get("nav_events") or []: + target_id = event.get("target_id") + target = entity_by_id.get(target_id or "") + route.append( + { + "id": event.get("id"), + "action": event.get("action"), + "target_id": target_id, + "success": bool(event.get("success", True)), + "guided": bool(event.get("guided")), + "retries": int(event.get("retries") or 0), + "elapsed_s": float(event.get("elapsed_s") or 0.0), + "note": event.get("note") or "", + "x": target.get("x") if target else None, + "y": target.get("y") if target else None, + } + ) + return route + + +def _latest_pose( + observations: list[dict[str, Any]], + route: list[dict[str, Any]], + zone_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + if observations: + latest = observations[-1] + return {"x": latest["x"], "y": latest["y"], "source": latest["source"]} + for event in reversed(route): + if event.get("success") and event.get("x") is not None and event.get("y") is not None: + return {"x": event["x"], "y": event["y"], "source": "last_nav_event"} + home = zone_by_id.get("HOME") + if home and home.get("x") is not None and home.get("y") is not None: + return {"x": home["x"], "y": home["y"], "source": "HOME"} + return None + + +def _offset_from_zone( + zone: dict[str, Any] | None, + zone_offsets: dict[str, int], + zone_id: str, + *, + radius: float, +) -> tuple[float | None, float | None]: + if zone is None or zone.get("x") is None or zone.get("y") is None: + return None, None + index = zone_offsets.get(zone_id, 0) + zone_offsets[zone_id] = index + 1 + angle = index * (math.pi * 0.55) + return float(zone["x"]) + math.cos(angle) * radius, float(zone["y"]) + math.sin(angle) * radius + + +def _bounds(points: list[dict[str, Any]]) -> dict[str, float]: + if not points: + return {"min_x": -1.0, "max_x": 1.0, "min_y": -1.0, "max_y": 1.0} + xs = [float(point["x"]) for point in points if point.get("x") is not None] + ys = [float(point["y"]) for point in points if point.get("y") is not None] + return { + "min_x": min(xs) - 0.8, + "max_x": max(xs) + 0.8, + "min_y": min(ys) - 0.8, + "max_y": max(ys) + 0.8, + } + + +def _costmap( + bounds: dict[str, float], + zones: list[dict[str, Any]], + assets: list[dict[str, Any]], + packages: list[dict[str, Any]], + route: list[dict[str, Any]], +) -> dict[str, Any]: + columns = 24 + rows = 16 + width = bounds["max_x"] - bounds["min_x"] + height = bounds["max_y"] - bounds["min_y"] + cell_w = width / columns + cell_h = height / rows + cells: list[dict[str, float]] = [] + blocked_packages = [ + package + for package in packages + if package.get("state") in {"missing", "wrong_zone", "blocked"} + or package.get("blocks_asset_id") + ] + no_go_zones = [zone for zone in zones if zone.get("no_go")] + + for row in range(rows): + for column in range(columns): + x = bounds["min_x"] + (column + 0.5) * cell_w + y = bounds["min_y"] + (row + 0.5) * cell_h + cost = 0.08 + for zone in no_go_zones: + cost = max(cost, _radial_cost(x, y, zone, radius_m=1.1, peak=0.95)) + for package in blocked_packages: + cost = max(cost, _radial_cost(x, y, package, radius_m=0.9, peak=0.82)) + for asset in assets: + if asset.get("expected_clear") is False: + cost = max(cost, _radial_cost(x, y, asset, radius_m=0.65, peak=0.65)) + route_bonus = max( + (_radial_cost(x, y, event, radius_m=0.45, peak=0.24) for event in route), + default=0.0, + ) + cells.append( + { + "x": bounds["min_x"] + column * cell_w, + "y": bounds["min_y"] + row * cell_h, + "width": cell_w, + "height": cell_h, + "cost": min(1.0, max(cost, route_bonus)), + } + ) + return { + "source": "DogOps-generated navigation costmap", + "columns": columns, + "rows": rows, + "cells": cells, + } + + +def _radial_cost( + x: float, + y: float, + point: dict[str, Any], + *, + radius_m: float, + peak: float, +) -> float: + point_x = point.get("x") + point_y = point.get("y") + if point_x is None or point_y is None: + return 0.0 + distance = math.hypot(x - float(point_x), y - float(point_y)) + if distance >= radius_m: + return 0.0 + return peak * (1.0 - distance / radius_m) + + +def _float_or_none(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/dimos/experimental/dogops/mission_engine.py b/dimos/experimental/dogops/mission_engine.py new file mode 100644 index 0000000000..ed482060fb --- /dev/null +++ b/dimos/experimental/dogops/mission_engine.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +from pathlib import Path +import time + +from dimos.experimental.dogops.config_loader import load_dogops_config +from dimos.experimental.dogops.models import ( + DogOpsConfig, + DogOpsState, + Incident, + IncidentState, + IncidentType, + MissionState, + NavEvent, + Observation, + PackageState, + Severity, + SimulationObservation, + WorkOrder, + WorkOrderState, +) +from dimos.experimental.dogops.nav_eval import summarize_nav_events +from dimos.experimental.dogops.report import assert_report_has_closed_loop +from dimos.experimental.dogops.store import DogOpsStore + + +class OfflineMissionEngine: + def __init__(self, config: DogOpsConfig, out_dir: str | Path) -> None: + self.config = config + self.store = DogOpsStore( + out_dir, + site=config.site, + manifest=config.manifest, + policy=config.policy, + mission=config.mission, + ) + self._obs_count = 0 + self._nav_count = 0 + + def run(self, *, verify_closed_loop: bool = True) -> DogOpsState: + started_at = time.time() + run = self.store.create_run(self.config.mission.mission_id, started_at=started_at) + state = self.store.state + assert state is not None + + self._record_nav_events(run.id) + self._localize_home(run.id) + self._scan("scan_inbound", run.id, state) + self._scan("inspect_cooling", run.id, state) + self._open_blocked_cooling_if_needed(run.id, state) + self._open_missing_package_incidents(run.id, state) + self._mark_work_order_ready("WO-001", state) + self._scan("verify_cooling_after_fix", run.id, state, source="simulated_human_fix") + self._verify_work_order("WO-001", state) + self._scan("scan_qa_hold", run.id, state) + + state.nav_summary = summarize_nav_events(run.id, state.nav_events) + summary = "Closed INC-001 after simulated human remediation; PKG-103 remains missing." + self.store.finish_run(run.id, MissionState.done, summary, ended_at=time.time()) + self.store.write_state(run.id) + self.store.write_report(run.id) + if verify_closed_loop: + assert_report_has_closed_loop(state) + return state + + def _record_nav_events(self, run_id: str) -> None: + for sim_event in self.config.mission.nav_simulation.events: + self._nav_count += 1 + event = NavEvent( + id=f"NAV-{self._nav_count:03d}", + run_id=run_id, + ts=time.time(), + action=sim_event.action, + target_id=sim_event.target_id, + success=sim_event.success, + elapsed_s=sim_event.elapsed_s, + retries=sim_event.retries, + guided=sim_event.guided or self.config.mission.nav_simulation.guided, + error_m=sim_event.error_m, + note=sim_event.note, + ) + self.store.append_nav_event(event) + + def _localize_home(self, run_id: str) -> None: + self._obs_count += 1 + obs = Observation( + id=f"OBS-{self._obs_count:03d}", + ts=time.time(), + run_id=run_id, + entity_id="HOME", + tag_id=10, + zone_id="HOME", + facts={"localized": True}, + confidence=1.0, + source="simulation", + ) + self.store.append_observation(obs) + + def _scan( + self, + key: str, + run_id: str, + state: DogOpsState, + *, + source: str = "simulation", + ) -> Observation: + sim_obs = self.config.mission.simulation_observations.get(key) + if sim_obs is None: + raise KeyError(f"missing simulation observation: {key}") + obs = self._observation_from_simulation(key, run_id, sim_obs, source=source) + self.store.append_observation(obs) + self._apply_observation_facts(obs, state) + return obs + + def _observation_from_simulation( + self, key: str, run_id: str, sim_obs: SimulationObservation, *, source: str + ) -> Observation: + self._obs_count += 1 + tag_to_entity = self.config.site.entity_for_tag() + primary_entity_id = None + primary_tag_id = sim_obs.visible_tag_ids[0] if sim_obs.visible_tag_ids else None + if primary_tag_id is not None and primary_tag_id in tag_to_entity: + primary_entity_id = tag_to_entity[primary_tag_id].id + facts: dict[str, bool | str | int | float] = { + "scan_key": key, + "visible_tag_ids": ",".join(str(tag_id) for tag_id in sim_obs.visible_tag_ids), + } + facts.update(sim_obs.facts) + return Observation( + id=f"OBS-{self._obs_count:03d}", + ts=time.time(), + run_id=run_id, + entity_id=primary_entity_id, + tag_id=primary_tag_id, + zone_id=sim_obs.zone_id, + facts=facts, + confidence=1.0, + source=source, + ) + + def _apply_observation_facts(self, obs: Observation, state: DogOpsState) -> None: + for key, value in obs.facts.items(): + if key.endswith(".zone_id"): + package_id = key.removesuffix(".zone_id") + status = state.package_statuses.get(package_id) + if status is None or not isinstance(value, str): + continue + previous_zone = status.observed_zone_id + status.observed_zone_id = value + if value == status.expected_zone_id: + status.state = PackageState.found_ok + status.blocks_asset_id = None + else: + status.state = PackageState.wrong_zone + if package_id == "PKG-104" and previous_zone in {"RACK_ROW_A", "COOLING_1"}: + if value == "QA_HOLD": + state.what_changed.append( + "PKG-104 moved from COOLING_1/RACK_ROW_A to QA_HOLD; INC-001 resolved." + ) + elif key.endswith(".blocks_asset_id"): + package_id = key.removesuffix(".blocks_asset_id") + status = state.package_statuses.get(package_id) + if status is None or not isinstance(value, str): + continue + status.blocks_asset_id = value + + def _open_blocked_cooling_if_needed(self, run_id: str, state: DogOpsState) -> None: + pkg_104 = state.package_statuses.get("PKG-104") + if pkg_104 is None or pkg_104.blocks_asset_id != "COOLING_1": + return + if any(incident.id == "INC-001" for incident in state.incidents): + return + rule = self.config.policy.rule_for_type(IncidentType.blocked_cooling) + severity = Severity(rule.severity) if rule else Severity.P1 + action = ( + rule.recommended_action + if rule + else "Move blocking package to QA_HOLD and verify COOLING_1 is clear." + ) + incident = Incident( + id="INC-001", + run_id=run_id, + ts_open=time.time(), + severity=severity, + type=IncidentType.blocked_cooling, + entity_id="COOLING_1", + related_package_id="PKG-104", + state=IncidentState.open, + title="PKG-104 wrong zone and blocking COOLING_1", + evidence_observation_ids=[obs.id for obs in state.observations if obs.zone_id == "RACK_ROW_A"], + recommended_action=action, + ) + work_order = WorkOrder( + id="WO-001", + incident_id=incident.id, + requested_action=action, + state=WorkOrderState.assigned, + ) + self.store.append_incident(incident) + self.store.append_work_order(work_order) + + def _open_missing_package_incidents(self, run_id: str, state: DogOpsState) -> None: + for package_id, status in state.package_statuses.items(): + if status.observed_zone_id is not None: + continue + if any(incident.related_package_id == package_id for incident in state.incidents): + continue + status.state = PackageState.missing + rule = self.config.policy.rule_for_type(IncidentType.missing_package) + incident = Incident( + id="INC-002", + run_id=run_id, + ts_open=time.time(), + severity=Severity(rule.severity) if rule else Severity.P2, + type=IncidentType.missing_package, + entity_id=package_id, + related_package_id=package_id, + state=IncidentState.open, + title=f"{package_id} missing from inbound scan", + evidence_observation_ids=[obs.id for obs in state.observations], + recommended_action=( + rule.recommended_action if rule else "Search inbound dock and QA_HOLD." + ), + ) + self.store.append_incident(incident) + + def _mark_work_order_ready(self, work_order_id: str, state: DogOpsState) -> None: + work_order = self._get_work_order(work_order_id, state) + work_order.state = WorkOrderState.ready_to_verify + self.store.update_work_order(work_order) + incident = self._get_incident(work_order.incident_id, state) + incident.state = IncidentState.ready_to_verify + self.store.update_incident(incident) + + def _verify_work_order(self, work_order_id: str, state: DogOpsState) -> None: + work_order = self._get_work_order(work_order_id, state) + incident = self._get_incident(work_order.incident_id, state) + pkg_104 = state.package_statuses["PKG-104"] + cooling_clear = any( + obs.facts.get("COOLING_1.clearance_clear") is True for obs in state.observations + ) + if pkg_104.observed_zone_id == "QA_HOLD" and cooling_clear: + incident.state = IncidentState.resolved + incident.ts_closed = time.time() + work_order.state = WorkOrderState.verified_closed + work_order.verification_observation_ids = [ + obs.id for obs in state.observations if obs.source == "simulated_human_fix" + ] + self.store.update_incident(incident) + self.store.update_work_order(work_order) + + @staticmethod + def _get_incident(incident_id: str, state: DogOpsState) -> Incident: + for incident in state.incidents: + if incident.id == incident_id: + return incident + raise KeyError(incident_id) + + @staticmethod + def _get_work_order(work_order_id: str, state: DogOpsState) -> WorkOrder: + for work_order in state.work_orders: + if work_order.id == work_order_id: + return work_order + raise KeyError(work_order_id) + + +def run_offline_simulation( + *, + site: str | Path = "examples/dogops/site_demo.yaml", + manifest: str | Path = "examples/dogops/manifest_demo.yaml", + mission: str | Path = "examples/dogops/mission_demo.yaml", + policy: str | Path = "examples/dogops/policy_demo.yaml", + out: str | Path = ".dogops/runs/latest", + verify_closed_loop: bool = True, +) -> DogOpsState: + config = load_dogops_config(site, manifest, mission, policy) + engine = OfflineMissionEngine(config, out) + return engine.run(verify_closed_loop=verify_closed_loop) diff --git a/dimos/experimental/dogops/models.py b/dimos/experimental/dogops/models.py new file mode 100644 index 0000000000..d18017014d --- /dev/null +++ b/dimos/experimental/dogops/models.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class DogOpsModel(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + + +class EntityKind(str, Enum): + zone = "zone" + asset = "asset" + package = "package" + dock = "dock" + portal = "portal" + + +class ZoneKind(str, Enum): + home = "home" + inbound_dock = "inbound_dock" + qa_hold = "qa_hold" + rack_row = "rack_row" + aisle = "aisle" + no_go = "no_go" + dock = "dock" + portal = "portal" + + +class AssetKind(str, Enum): + cooling_clearance = "cooling_clearance" + rack_status = "rack_status" + aisle_clearance = "aisle_clearance" + safety_station = "safety_station" + temperature_station = "temperature_station" + + +class PackageState(str, Enum): + expected = "expected" + found_ok = "found_ok" + wrong_zone = "wrong_zone" + missing = "missing" + damaged = "damaged" + blocking_asset = "blocking_asset" + unknown = "unknown" + + +class IncidentType(str, Enum): + blocked_cooling = "blocked_cooling" + wrong_zone = "wrong_zone" + missing_package = "missing_package" + damaged_package = "damaged_package" + blocked_aisle = "blocked_aisle" + no_go_breach = "no_go_breach" + high_temperature = "high_temperature" + unknown = "unknown" + + +class Severity(str, Enum): + P1 = "P1" + P2 = "P2" + P3 = "P3" + INFO = "INFO" + + +class IncidentState(str, Enum): + open = "open" + acked = "acked" + ready_to_verify = "ready_to_verify" + resolved = "resolved" + unresolved = "unresolved" + false_positive = "false_positive" + + +class WorkOrderState(str, Enum): + open = "open" + assigned = "assigned" + ready_to_verify = "ready_to_verify" + verified_closed = "verified_closed" + blocked = "blocked" + cancelled = "cancelled" + + +class MissionState(str, Enum): + init = "init" + running = "running" + waiting_for_human = "waiting_for_human" + verifying = "verifying" + done = "done" + failed = "failed" + stopped = "stopped" + + +class NavAction(str, Enum): + goto = "goto" + scan = "scan" + search_tag = "search_tag" + rotate = "rotate" + step_back = "step_back" + guided = "guided" + dock_align = "dock_align" + portal_entry = "portal_entry" + + +class Pose2D(DogOpsModel): + x: float | None = None + y: float | None = None + theta_deg: float | None = None + frame: str = "world" + source: str = "unknown" + + +class SiteEntity(DogOpsModel): + id: str + kind: EntityKind + tag_id: int | None = None + display_name: str + zone_id: str | None = None + expected_state: dict[str, Any] = Field(default_factory=dict) + severity_if_failed: Severity = Severity.P3 + notes: str = "" + + +class Zone(SiteEntity): + kind: Literal[EntityKind.zone] = EntityKind.zone + zone_kind: ZoneKind + pose_hint: Pose2D | None = None + radius_m: float = 0.8 + no_go: bool = False + + +class Asset(SiteEntity): + kind: Literal[EntityKind.asset] = EntityKind.asset + asset_kind: AssetKind + expected_clear: bool | None = None + expected_status: str | None = None + blocking_package_ids: list[str] = Field(default_factory=list) + + +class Package(SiteEntity): + kind: Literal[EntityKind.package] = EntityKind.package + expected_zone_id: str + expected_condition: str = "ok" + + +class SpecialEntity(SiteEntity): + kind: EntityKind + + +class SiteConfig(DogOpsModel): + site_id: str + site_name: str = "" + tag_family: str = "tag36h11" + marker_length_m: float + zones: list[Zone] = Field(default_factory=list) + assets: list[Asset] = Field(default_factory=list) + packages: list[Package] = Field(default_factory=list) + special_entities: dict[str, SpecialEntity] = Field(default_factory=dict) + + def package_by_id(self) -> dict[str, Package]: + return {pkg.id: pkg for pkg in self.packages} + + def asset_by_id(self) -> dict[str, Asset]: + return {asset.id: asset for asset in self.assets} + + def zone_by_id(self) -> dict[str, Zone]: + return {zone.id: zone for zone in self.zones} + + def entity_for_tag(self) -> dict[int, SiteEntity]: + entities: list[SiteEntity] = [*self.zones, *self.assets, *self.packages] + entities.extend(self.special_entities.values()) + return {entity.tag_id: entity for entity in entities if entity.tag_id is not None} + + +class ManifestItem(DogOpsModel): + package_id: str + expected_zone_id: str + expected_condition: str = "ok" + + +class Manifest(DogOpsModel): + manifest_id: str + items: list[ManifestItem] + + def item_by_package_id(self) -> dict[str, ManifestItem]: + return {item.package_id: item for item in self.items} + + +class PolicyRule(DogOpsModel): + id: str + severity: Severity = Severity.P3 + incident_type: IncidentType = IncidentType.unknown + description: str + condition: dict[str, Any] = Field(default_factory=dict) + recommended_action: str = "" + + +class PolicyConfig(DogOpsModel): + policy_id: str + rules: list[PolicyRule] = Field(default_factory=list) + + def rule_for_type(self, incident_type: IncidentType | str) -> PolicyRule | None: + type_value = incident_type.value if isinstance(incident_type, IncidentType) else incident_type + for rule in self.rules: + if rule.incident_type == type_value: + return rule + return None + + +class MissionStep(DogOpsModel): + id: str + action: str + target_id: str + timeout_s: float = 30.0 + required: bool = True + + +class SimulationObservation(DogOpsModel): + zone_id: str + visible_tag_ids: list[int] = Field(default_factory=list) + facts: dict[str, bool | str | int | float] = Field(default_factory=dict) + + +class NavSimulationEvent(DogOpsModel): + target_id: str + action: NavAction = NavAction.goto + success: bool = True + elapsed_s: float = 0.0 + retries: int = 0 + guided: bool = False + error_m: float | None = None + note: str = "" + + +class NavSimulation(DogOpsModel): + guided: bool = False + events: list[NavSimulationEvent] = Field(default_factory=list) + + +class MissionConfig(DogOpsModel): + mission_id: str + display_name: str + steps: list[MissionStep] + verify_after_human: bool = True + simulation_observations: dict[str, SimulationObservation] = Field(default_factory=dict) + nav_simulation: NavSimulation = Field(default_factory=NavSimulation) + + +class Observation(DogOpsModel): + id: str + ts: float + run_id: str + entity_id: str | None = None + tag_id: int | None = None + zone_id: str | None = None + pose: Pose2D | None = None + image_path: str | None = None + facts: dict[str, bool | str | int | float] = Field(default_factory=dict) + confidence: float = 1.0 + source: str = "simulation" + + +class Incident(DogOpsModel): + id: str + run_id: str + ts_open: float + ts_closed: float | None = None + severity: Severity + type: IncidentType + entity_id: str + related_package_id: str | None = None + state: IncidentState + title: str + evidence_observation_ids: list[str] = Field(default_factory=list) + recommended_action: str = "" + + +class WorkOrder(DogOpsModel): + id: str + incident_id: str + requested_action: str + assignee: str = "human_operator" + state: WorkOrderState + verification_observation_ids: list[str] = Field(default_factory=list) + + +class NavEvent(DogOpsModel): + id: str + run_id: str + ts: float + action: NavAction + target_id: str | None = None + success: bool = True + elapsed_s: float = 0.0 + retries: int = 0 + guided: bool = False + error_m: float | None = None + note: str = "" + + +class NavSummary(DogOpsModel): + run_id: str + waypoints_total: int + waypoints_reached: int + waypoints_failed: int + success_rate: float + route_targets: int = 0 + unique_targets_reached: int = 0 + route_coverage: float = 0.0 + retries_total: int + guided_interventions: int + guided_fallback_used: bool = False + tag_reacquisition_attempts: int + tag_reacquisition_successes: int + tag_reacquisition_rate: float = 0.0 + mean_elapsed_s: float + worst_target_id: str | None = None + safety_stops: int = 0 + notes: list[str] = Field(default_factory=list) + + +class MissionRun(DogOpsModel): + id: str + mission_id: str + started_at: float + ended_at: float | None = None + state: MissionState + current_step_id: str | None = None + summary: str = "" + + +class PackageStatus(DogOpsModel): + package_id: str + expected_zone_id: str + observed_zone_id: str | None = None + state: PackageState = PackageState.expected + blocks_asset_id: str | None = None + + +class DogOpsState(DogOpsModel): + run: MissionRun + site: SiteConfig + manifest: Manifest + policy: PolicyConfig + mission: MissionConfig + package_statuses: dict[str, PackageStatus] = Field(default_factory=dict) + observations: list[Observation] = Field(default_factory=list) + incidents: list[Incident] = Field(default_factory=list) + work_orders: list[WorkOrder] = Field(default_factory=list) + nav_events: list[NavEvent] = Field(default_factory=list) + nav_summary: NavSummary | None = None + what_changed: list[str] = Field(default_factory=list) + + +class DogOpsConfig(DogOpsModel): + site: SiteConfig + manifest: Manifest + policy: PolicyConfig + mission: MissionConfig + paths: dict[str, Path] diff --git a/dimos/experimental/dogops/nav_eval.py b/dimos/experimental/dogops/nav_eval.py new file mode 100644 index 0000000000..00144d5460 --- /dev/null +++ b/dimos/experimental/dogops/nav_eval.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from pathlib import Path +from statistics import mean + +from dimos.experimental.dogops.models import NavAction, NavEvent, NavSummary +from dimos.experimental.dogops.store import DogOpsStore + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.module import Module +except ModuleNotFoundError: + + class Module: + def __init__(self, **_: object) -> None: + pass + + @classmethod + def blueprint(cls, **kwargs: object) -> dict[str, object]: + return {"module": cls.__name__, "kwargs": kwargs} + + +def summarize_nav_events(run_id: str, events: list[NavEvent]) -> NavSummary: + waypoint_events = [event for event in events if event.action == NavAction.goto] + reached = [event for event in waypoint_events if event.success] + failed = [event for event in waypoint_events if not event.success] + elapsed_values = [event.elapsed_s for event in waypoint_events if event.elapsed_s > 0] + tag_attempts = [ + event + for event in events + if event.action == NavAction.search_tag or "tag" in event.note.lower() + ] + guided = [event for event in events if event.guided or event.action == NavAction.guided] + worst = max(waypoint_events, key=lambda event: event.elapsed_s, default=None) + notes = [event.note for event in events if event.note] + route_targets = {event.target_id for event in waypoint_events if event.target_id is not None} + reached_targets = {event.target_id for event in reached if event.target_id is not None} + tag_successes = len([event for event in tag_attempts if event.success]) + return NavSummary( + run_id=run_id, + waypoints_total=len(waypoint_events), + waypoints_reached=len(reached), + waypoints_failed=len(failed), + success_rate=(len(reached) / len(waypoint_events)) if waypoint_events else 0.0, + route_targets=len(route_targets), + unique_targets_reached=len(reached_targets), + route_coverage=(len(reached_targets) / len(route_targets)) if route_targets else 0.0, + retries_total=sum(event.retries for event in events), + guided_interventions=len(guided), + guided_fallback_used=bool(guided), + tag_reacquisition_attempts=len(tag_attempts), + tag_reacquisition_successes=tag_successes, + tag_reacquisition_rate=(tag_successes / len(tag_attempts)) if tag_attempts else 0.0, + mean_elapsed_s=mean(elapsed_values) if elapsed_values else 0.0, + worst_target_id=worst.target_id if worst else None, + safety_stops=len([event for event in events if "safety" in event.note.lower()]), + notes=notes, + ) + + +class DogOpsNavEvalModule(Module): + def __init__(self, **_: object) -> None: + if _: + super().__init__(**_) + + def summarize_run(self, run_dir: str | Path = ".dogops/runs/latest") -> str: + store = DogOpsStore.load_existing(run_dir) + state = store.state + assert state is not None + summary = summarize_nav_events(state.run.id, state.nav_events) + return json.dumps(summary.model_dump(mode="json"), sort_keys=True) diff --git a/dimos/experimental/dogops/observation_module.py b/dimos/experimental/dogops/observation_module.py new file mode 100644 index 0000000000..135292b19b --- /dev/null +++ b/dimos/experimental/dogops/observation_module.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from dimos.experimental.dogops.config_loader import DEFAULT_SITE, load_site_config +from dimos.experimental.dogops.detector import DetectedTag, DogOpsTagDetector +from dimos.experimental.dogops.models import Observation + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from reactivex.disposable import Disposable as RxDisposable +except ModuleNotFoundError: # pragma: no cover - local project-pack fallback. + RxDisposable = None + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.module import Module +except ModuleNotFoundError: + + class Module: + def __init__(self, **_: object) -> None: + pass + + @classmethod + def blueprint(cls, **kwargs: object) -> dict[str, object]: + return {"module": cls.__name__, "kwargs": kwargs} + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.stream import In + from dimos.msgs.sensor_msgs.Image import Image +except ModuleNotFoundError: # pragma: no cover - local project-pack fallback. + + class In: + def __class_getitem__(cls, _: object) -> type[In]: + return cls + + Image = Any # type: ignore[misc, assignment] + + +class DogOpsObservationModule(Module): + color_image: In[Image] + + def __init__(self, *, site_path: str | Path = DEFAULT_SITE, **_: object) -> None: + super().__init__(**_) + self.site_path = Path(site_path) + self.site = load_site_config(self.site_path) + self.detector = DogOpsTagDetector(self.site) + self._latest_image: object | None = None + self._latest_image_received_at: float | None = None + + def start(self) -> None: # pragma: no cover - lifecycle exercised in full DimOS. + super().start() + color_image = getattr(self, "color_image", None) + if color_image is not None and hasattr(color_image, "subscribe"): + subscription = color_image.subscribe(self.ingest_camera_image) + _register_subscription(self, subscription) + + def ingest_camera_image(self, image: object) -> None: + self._latest_image = image + self._latest_image_received_at = time.time() + + def observe_simulated_tags( + self, + tag_ids: list[int], + *, + zone_id: str, + run_id: str = "simulated", + ) -> list[Observation]: + return [ + self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id) + for detection in self.detector.detect_simulated(tag_ids) + ] + + def observe_image( + self, + image_path: str | Path, + *, + zone_id: str, + run_id: str = "image", + ) -> list[Observation]: + return [ + self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id) + for detection in self.detector.detect_image(image_path) + ] + + def observe_latest_image( + self, + *, + zone_id: str, + run_id: str = "camera", + ) -> list[Observation]: + if self._latest_image is None: + return [] + return [ + self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id) + for detection in self.detector.detect_dimos_image(self._latest_image) + ] + + def image_stream_status(self) -> dict[str, object]: + if self._latest_image is not None and self._latest_image_received_at is not None: + shape = getattr(self._latest_image, "shape", None) + data = getattr(self._latest_image, "data", None) + if shape is None and data is not None: + shape = getattr(data, "shape", None) + return { + "ok": True, + "mode": "latest_frame", + "frame_age_s": round(time.time() - self._latest_image_received_at, 3), + "frame_id": getattr(self._latest_image, "frame_id", None), + "shape": [int(item) for item in shape] if shape is not None else None, + } + return { + "ok": False, + "mode": "not_subscribed", + "fallback": "use observe_simulated_tags or observe_image", + } + + @staticmethod + def _observation_for_detection( + detection: DetectedTag, + *, + zone_id: str, + run_id: str, + ) -> Observation: + return Observation( + id=f"TAG-{detection.tag_id}", + ts=time.time(), + run_id=run_id, + entity_id=detection.entity_id, + tag_id=detection.tag_id, + zone_id=zone_id, + facts={ + "entity_kind": detection.entity_kind or "unknown", + "detection_source": detection.source, + "frame_id": detection.frame_id or "", + "center_px": _format_point(detection.center_px), + "area_px": round(detection.area_px, 3) if detection.area_px is not None else 0.0, + }, + confidence=detection.confidence, + source=detection.source, + ) + + +def _format_point(point: tuple[float, float] | None) -> str: + if point is None: + return "" + return f"{point[0]:.1f},{point[1]:.1f}" + + +def _register_subscription(owner: object, subscription: object) -> None: + register = getattr(owner, "register_disposable", None) + if not callable(register): + return + if callable(subscription) and RxDisposable is not None: + register(RxDisposable(subscription)) + return + register(subscription) diff --git a/dimos/experimental/dogops/qr_cargo.py b/dimos/experimental/dogops/qr_cargo.py new file mode 100644 index 0000000000..674a7eefec --- /dev/null +++ b/dimos/experimental/dogops/qr_cargo.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +import time +from typing import Any +import uuid + + +QR_EVENTS_FILENAME = "qr_events.jsonl" +QR_CARGO_STATE_FILENAME = "qr_cargo_state.json" +QR_CARGO_STATE_SCHEMA_VERSION = 1 +QR_LATEST_LIMIT = 50 +QR_REQUIRED_PAYLOAD_FIELDS = ( + "warehouse_id", + "location_node_id", + "cargo_id", + "task", +) + + +def validate_qr_payload(payload: dict[str, Any]) -> tuple[bool, list[str]]: + errors: list[str] = [] + if not isinstance(payload, dict): + return False, ["qr_payload must be an object"] + if payload.get("type") != "cargo": + errors.append("qr_payload.type must be cargo") + for field in QR_REQUIRED_PAYLOAD_FIELDS: + if not _non_empty_text(payload.get(field)): + errors.append(f"qr_payload.{field} is required") + return not errors, errors + + +def normalize_qr_event(event: dict[str, Any]) -> dict[str, Any]: + if not isinstance(event, dict): + raise ValueError("QR event must be an object") + + status = str(event.get("status") or "").strip() + if not status: + raise ValueError("event.status is required") + + action_policy = str(event.get("action_policy") or "report_only").strip() or "report_only" + if action_policy != "report_only": + raise ValueError("event.action_policy must be report_only") + + qr_payload, qr_payload_raw = _qr_payload_from_event(event) + ok, errors = validate_qr_payload(qr_payload) + if not ok: + raise ValueError("; ".join(errors)) + + timestamp = _float_or_none(event.get("timestamp")) + if timestamp is None: + timestamp = time.time() + + event_id = str(event.get("event_id") or "").strip() + if not event_id: + event_id = _new_event_id(timestamp, qr_payload) + + return { + "event_id": event_id, + "timestamp": timestamp, + "source": str(event.get("source") or "unknown"), + "status": status, + "qr_payload_raw": qr_payload_raw, + "qr_payload": qr_payload, + "robot_pose_at_detection": _normalize_robot_pose( + event.get("robot_pose_at_detection") + ), + "bbox_px": _normalize_bbox(event.get("bbox_px")), + "action_policy": action_policy, + } + + +def load_qr_events(run_dir: Path) -> list[dict[str, Any]]: + path = qr_events_path(run_dir) + if not path.exists(): + return [] + events: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + raw = line.strip() + if not raw: + continue + try: + event = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid QR event JSON on line {line_number}: {path}") from exc + if not isinstance(event, dict): + raise ValueError(f"QR event line {line_number} must contain an object: {path}") + events.append(event) + return events + + +def append_qr_event(run_dir: Path, event: dict[str, Any]) -> dict[str, Any]: + root = Path(run_dir) + root.mkdir(parents=True, exist_ok=True) + normalized = normalize_qr_event(event) + path = qr_events_path(root) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(normalized, sort_keys=True) + "\n") + events = load_qr_events(root) + _write_qr_cargo_state(root, events) + return normalized + + +def get_latest_qr_events(run_dir: Path, limit: int = QR_LATEST_LIMIT) -> list[dict[str, Any]]: + events = load_qr_events(run_dir) + safe_limit = max(1, int(limit or QR_LATEST_LIMIT)) + return list(reversed(events[-safe_limit:])) + + +def get_qr_event(run_dir: Path, event_id: str) -> dict[str, Any] | None: + for event in reversed(load_qr_events(run_dir)): + if str(event.get("event_id") or "") == event_id: + return event + return None + + +def qr_events_path(run_dir: str | Path) -> Path: + return Path(run_dir) / QR_EVENTS_FILENAME + + +def qr_cargo_state_path(run_dir: str | Path) -> Path: + return Path(run_dir) / QR_CARGO_STATE_FILENAME + + +def _write_qr_cargo_state(run_dir: Path, events: list[dict[str, Any]]) -> None: + latest_by_cargo_id: dict[str, dict[str, Any]] = {} + for event in reversed(events): + payload = event.get("qr_payload") if isinstance(event.get("qr_payload"), dict) else {} + cargo_id = str(payload.get("cargo_id") or "") + if cargo_id and cargo_id not in latest_by_cargo_id: + latest_by_cargo_id[cargo_id] = event + + state = { + "schema_version": QR_CARGO_STATE_SCHEMA_VERSION, + "updated_at": time.time(), + "events_total": len(events), + "latest_events": list(reversed(events[-QR_LATEST_LIMIT:])), + "latest_by_cargo_id": latest_by_cargo_id, + } + path = qr_cargo_state_path(run_dir) + raw = json.dumps(state, indent=2, sort_keys=True) + tmp_path = path.with_name( + f"{path.name}.{os.getpid()}.{time.time_ns()}.tmp" + ) + tmp_path.write_text(raw + "\n", encoding="utf-8") + tmp_path.replace(path) + + +def _qr_payload_from_event(event: dict[str, Any]) -> tuple[dict[str, Any], str]: + payload = event.get("qr_payload") + raw = event.get("qr_payload_raw") + + if isinstance(payload, dict): + payload_dict = dict(payload) + payload_raw = raw if isinstance(raw, str) else json.dumps(payload_dict, sort_keys=True) + return payload_dict, payload_raw + + if isinstance(payload, str): + payload_dict = _decode_payload_string(payload, "qr_payload") + return payload_dict, raw if isinstance(raw, str) else payload + + if isinstance(raw, str): + return _decode_payload_string(raw, "qr_payload_raw"), raw + + raise ValueError("event.qr_payload or event.qr_payload_raw is required") + + +def _decode_payload_string(raw: str, field: str) -> dict[str, Any]: + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"event.{field} must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError(f"event.{field} must decode to an object") + return payload + + +def _normalize_robot_pose(value: Any) -> dict[str, Any]: + pose = value if isinstance(value, dict) else {} + return { + "frame": str(pose.get("frame") or "map"), + "x": _float_or_none(pose.get("x")), + "y": _float_or_none(pose.get("y")), + "yaw": _float_or_none(pose.get("yaw")), + } + + +def _normalize_bbox(value: Any) -> list[list[float]] | None: + if not isinstance(value, list): + return None + points: list[list[float]] = [] + for point in value: + if not isinstance(point, list | tuple) or len(point) != 2: + return None + x = _float_or_none(point[0]) + y = _float_or_none(point[1]) + if x is None or y is None: + return None + points.append([x, y]) + return points or None + + +def _float_or_none(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _non_empty_text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _new_event_id(timestamp: float, payload: dict[str, Any]) -> str: + cargo_id = str(payload.get("cargo_id") or "cargo").strip() or "cargo" + cargo_slug = "".join(ch if ch.isalnum() else "-" for ch in cargo_id.lower()).strip("-") + return f"qr-{cargo_slug}-{int(timestamp * 1000)}-{uuid.uuid4().hex[:8]}" diff --git a/dimos/experimental/dogops/report.py b/dimos/experimental/dogops/report.py new file mode 100644 index 0000000000..5d145da408 --- /dev/null +++ b/dimos/experimental/dogops/report.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from dimos.experimental.dogops.models import ( + DogOpsState, + IncidentState, + IncidentType, + NavAction, + Observation, + PackageState, + SiteEntity, + WorkOrderState, +) + + +def _value(value: object) -> object: + return getattr(value, "value", value) + + +def build_checkpoint_verifications(state: DogOpsState) -> list[dict[str, object]]: + entities_by_id: dict[str, SiteEntity] = { + **state.site.zone_by_id(), + **state.site.asset_by_id(), + **state.site.package_by_id(), + } + entities_by_id.update(state.site.special_entities) + checkpoints: list[dict[str, object]] = [] + seen_targets: set[str] = set() + + for event in state.nav_events: + if event.action != NavAction.goto or not event.target_id: + continue + target_id = event.target_id + if target_id in seen_targets: + continue + seen_targets.add(target_id) + entity = entities_by_id.get(target_id) + expected_tag_id = entity.tag_id if entity is not None else None + matching_observation = _first_observation_with_tag(state.observations, expected_tag_id) + checkpoints.append( + { + "target_id": target_id, + "expected_tag_id": expected_tag_id, + "verified": matching_observation is not None, + "observation_id": matching_observation.id if matching_observation else None, + } + ) + + return checkpoints + + +def build_report_data(state: DogOpsState) -> dict[str, object]: + package_statuses = list(state.package_statuses.values()) + current_exception_package_ids = { + status.package_id + for status in package_statuses + if status.state + in {PackageState.missing, PackageState.wrong_zone, PackageState.blocking_asset} + } + incident_exception_package_ids = { + incident.related_package_id + for incident in state.incidents + if incident.related_package_id is not None + and incident.type + in { + IncidentType.blocked_cooling, + IncidentType.wrong_zone, + IncidentType.missing_package, + IncidentType.damaged_package, + } + } + manifest_exception_package_ids = current_exception_package_ids | incident_exception_package_ids + verified_work_orders = [ + work_order + for work_order in state.work_orders + if work_order.state == WorkOrderState.verified_closed + ] + open_incidents = [ + incident for incident in state.incidents if incident.state != IncidentState.resolved + ] + resolved_incidents = [ + incident for incident in state.incidents if incident.state == IncidentState.resolved + ] + nav = state.nav_summary + checkpoints = build_checkpoint_verifications(state) + return { + "run_id": state.run.id, + "mission_id": state.run.mission_id, + "mission_state": _value(state.run.state), + "summary": state.run.summary, + "packages_expected": len(package_statuses), + "packages_observed": len( + [status for status in package_statuses if status.observed_zone_id is not None] + ), + "manifest_exceptions": len(manifest_exception_package_ids), + "incidents_opened": len(state.incidents), + "incidents_resolved": len(resolved_incidents), + "work_orders_verified_closed": len(verified_work_orders), + "open_issues": [ + { + "incident_id": incident.id, + "type": _value(incident.type), + "entity_id": incident.entity_id, + "related_package_id": incident.related_package_id, + "state": _value(incident.state), + } + for incident in open_incidents + ], + "what_changed": state.what_changed, + "nav_summary": nav.model_dump(mode="json") if nav else None, + "checkpoints_total": len(checkpoints), + "checkpoints_verified": len([checkpoint for checkpoint in checkpoints if checkpoint["verified"]]), + "checkpoint_verifications": checkpoints, + "packages": [status.model_dump(mode="json") for status in package_statuses], + "incidents": [incident.model_dump(mode="json") for incident in state.incidents], + "work_orders": [ + work_order.model_dump(mode="json") for work_order in state.work_orders + ], + } + + +def render_report_markdown(state: DogOpsState) -> str: + data = build_report_data(state) + nav = state.nav_summary + lines = [ + "DOGOPS RUN REPORT", + f"Mission: {state.run.mission_id}", + ( + "Packages scanned: " + f"{data['packages_observed']} observed / {data['packages_expected']} expected" + ), + f"Manifest exceptions: {data['manifest_exceptions']}", + f"Incidents opened: {data['incidents_opened']}", + f"Work orders verified closed: {data['work_orders_verified_closed']}", + ] + + open_issues = data["open_issues"] + if open_issues: + issue_text = ", ".join( + f"{issue['related_package_id'] or issue['entity_id']} {issue['type']}" + for issue in open_issues # type: ignore[union-attr] + ) + lines.append(f"Open issue: {issue_text}") + else: + lines.append("Open issue: none") + + if nav: + lines.append( + "Nav: " + f"{nav.waypoints_reached}/{nav.waypoints_total} waypoints reached, " + f"{nav.tag_reacquisition_successes} tag-search recovery, " + f"{nav.safety_stops} safety stops" + ) + for note in nav.notes: + lines.append(f"Nav evidence: {note}") + lines.append( + "Checkpoints: " + f"{data['checkpoints_verified']}/{data['checkpoints_total']} tag sign-ins verified" + ) + + for changed in state.what_changed: + lines.append(f"What changed: {changed}") + + lines.extend(["", "Incidents:"]) + for incident in state.incidents: + lines.append( + "- " + f"{incident.id} {_value(incident.severity)} {_value(incident.type)}: " + f"{incident.title} [{_value(incident.state)}]" + ) + + lines.extend(["", "Packages:"]) + for status in state.package_statuses.values(): + location = status.observed_zone_id or "not observed" + blocker = f", blocks {status.blocks_asset_id}" if status.blocks_asset_id else "" + lines.append( + f"- {status.package_id}: {_value(status.state)}, expected {status.expected_zone_id}, " + f"observed {location}{blocker}" + ) + + return "\n".join(lines) + "\n" + + +def _first_observation_with_tag( + observations: list[Observation], + tag_id: int | None, +) -> Observation | None: + if tag_id is None: + return None + for observation in observations: + if tag_id in _observation_tag_ids(observation): + return observation + return None + + +def _observation_tag_ids(observation: Observation) -> set[int]: + tag_ids = set() + if observation.tag_id is not None: + tag_ids.add(observation.tag_id) + raw_visible = observation.facts.get("visible_tag_ids") + if isinstance(raw_visible, str): + for item in raw_visible.split(","): + item = item.strip() + if not item: + continue + try: + tag_ids.add(int(item)) + except ValueError: + continue + elif isinstance(raw_visible, int): + tag_ids.add(raw_visible) + return tag_ids + + +def assert_report_has_closed_loop(state: DogOpsState) -> None: + incidents_by_id = {incident.id: incident for incident in state.incidents} + work_orders_by_id = {work_order.id: work_order for work_order in state.work_orders} + pkg_103 = state.package_statuses.get("PKG-103") + pkg_104 = state.package_statuses.get("PKG-104") + if incidents_by_id.get("INC-001") is None: + raise AssertionError("INC-001 was not opened") + if incidents_by_id["INC-001"].state != IncidentState.resolved: + raise AssertionError("INC-001 was not verified closed") + if work_orders_by_id.get("WO-001") is None: + raise AssertionError("WO-001 was not opened") + if work_orders_by_id["WO-001"].state != WorkOrderState.verified_closed: + raise AssertionError("WO-001 was not verified closed") + if not pkg_103 or pkg_103.state != PackageState.missing: + raise AssertionError("PKG-103 must remain missing/open") + if not pkg_104 or pkg_104.observed_zone_id != "QA_HOLD": + raise AssertionError("PKG-104 must be moved to QA_HOLD") + if not state.nav_summary: + raise AssertionError("nav summary is missing") + if not any(incident.type == IncidentType.missing_package for incident in state.incidents): + raise AssertionError("missing package incident is missing") diff --git a/dimos/experimental/dogops/route_actions.py b/dimos/experimental/dogops/route_actions.py new file mode 100644 index 0000000000..88d313aebd --- /dev/null +++ b/dimos/experimental/dogops/route_actions.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import json +import os +import shutil +import struct +from typing import Any, Literal +import zlib + +from pydantic import Field + +from dimos.experimental.dogops.models import DogOpsModel + + +RouteActionKind = Literal[ + "scan_tags", + "scan_qr", + "capture_image", + "gemini_inspect_image", + "inspect_asset", + "verify_work_order", + "wait", + "operator_prompt", +] + + +class EditableRouteAction(DogOpsModel): + id: str + kind: RouteActionKind + label: str | None = None + required: bool = True + timeout_s: float = 5.0 + args: dict[str, Any] = Field(default_factory=dict) + + +class RouteActionResult(DogOpsModel): + ok: bool + state: Literal["completed", "failed", "skipped"] = "completed" + note: str = "" + payload: dict[str, Any] = Field(default_factory=dict) + evidence: list[dict[str, Any]] = Field(default_factory=list) + + +ScanZoneHandler = Callable[[str], str | dict[str, Any]] +CaptureImageHandler = Callable[[dict[str, Any]], dict[str, Any] | None] + + +def execute_route_action( + action: EditableRouteAction, + *, + run_dir: str | Path, + route_run_id: str, + waypoint_id: str, + route_id: str | None = None, + target_id: str | None = None, + pose: dict[str, Any] | None = None, + scan_zone_handler: ScanZoneHandler | None = None, + capture_image_handler: CaptureImageHandler | None = None, +) -> RouteActionResult: + if action.kind == "scan_tags": + expected = [int(tag) for tag in action.args.get("expected", [])] + return RouteActionResult( + ok=True, + note=f"tag scan demo result: {len(expected)} expected", + payload={"expected_tag_ids": expected, "detected_tag_ids": expected, "source": "demo"}, + evidence=[ + { + "kind": "tag_detection", + "path": None, + "mime_type": "application/json", + "metadata": { + "source": "demo", + "expected_tag_ids": expected, + "detected_tag_ids": expected, + }, + } + ], + ) + if action.kind == "scan_qr": + expected = [str(item) for item in action.args.get("expected", [])] + zone_id = _scan_zone_id_for_action(action, target_id=target_id, waypoint_id=waypoint_id) + if scan_zone_handler is not None and zone_id: + scan_result = _call_scan_zone(scan_zone_handler, zone_id) + if scan_result.get("ok") is True: + return _qr_result_from_scan_zone(expected, scan_result) + return RouteActionResult( + ok=False, + state="failed", + note=f"QR scan zone failed: {scan_result.get('error') or 'scan_zone_failed'}", + payload={"source": "scan_zone", "zone_id": zone_id, "scan_zone": scan_result}, + ) + payloads = expected + if not payloads and action.args.get("payload"): + payloads = [str(action.args["payload"])] + if not payloads: + return RouteActionResult( + ok=False, + state="failed", + note="QR scan configured without expected payloads", + payload={"source": "not_configured"}, + ) + return RouteActionResult( + ok=True, + note=f"QR scan demo result: {len(payloads)} payloads", + payload={"expected_payloads": payloads, "detected_payloads": payloads, "source": "demo"}, + evidence=[ + { + "kind": "qr_detection", + "path": None, + "mime_type": "application/json", + "metadata": { + "source": "demo", + "expected_payloads": payloads, + "detected_payloads": payloads, + }, + } + ], + ) + if action.kind == "capture_image": + image_path, source, mime_type, capture_metadata = _capture_image_path( + run_dir=Path(run_dir), + route_run_id=route_run_id, + waypoint_id=waypoint_id, + action=action, + capture_image_handler=capture_image_handler, + ) + return RouteActionResult( + ok=True, + note=( + "live Go2 camera image captured" + if source == "go2_camera_live" + else ( + "configured Go2 camera image captured" + if source == "go2_camera_configured" + else "placeholder dog-camera image captured" + ) + ), + payload={"source": source, "path": str(image_path)}, + evidence=[ + { + "kind": "image", + "path": str(image_path), + "mime_type": mime_type, + "metadata": { + "source": source, + "route_run_id": route_run_id, + "route_id": route_id, + "waypoint_id": waypoint_id, + "action_id": action.id, + "target": action.args.get("target") or target_id or waypoint_id, + "pose": pose, + **capture_metadata, + }, + } + ], + ) + if action.kind == "gemini_inspect_image": + return _execute_gemini_inspect_image( + action, + run_dir=Path(run_dir), + route_run_id=route_run_id, + waypoint_id=waypoint_id, + route_id=route_id, + target_id=target_id, + pose=pose, + ) + if action.kind == "wait": + return RouteActionResult( + ok=True, + note=f"wait completed ({float(action.args.get('seconds', 0.0)):.1f}s demo)", + payload={"seconds": float(action.args.get("seconds", 0.0)), "source": "demo"}, + ) + if action.kind in {"inspect_asset", "verify_work_order", "operator_prompt"}: + return RouteActionResult( + ok=True, + note=f"{action.kind} completed by deterministic demo handler", + payload={"source": "demo", **action.args}, + ) + return RouteActionResult(ok=False, state="failed", note=f"unsupported action: {action.kind}") + + +def _scan_zone_id_for_action( + action: EditableRouteAction, + *, + target_id: str | None, + waypoint_id: str, +) -> str: + for key in ("zone_id", "target_id", "target"): + value = action.args.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return (target_id or waypoint_id).strip() + + +def _call_scan_zone(handler: ScanZoneHandler, zone_id: str) -> dict[str, Any]: + raw = handler(zone_id) + if isinstance(raw, str): + payload = json.loads(raw) + else: + payload = raw + if not isinstance(payload, dict): + raise ValueError("scan_zone handler returned a non-object payload") + return payload + + +def _qr_result_from_scan_zone( + expected: list[str], + scan_result: dict[str, Any], +) -> RouteActionResult: + detected_payloads = [str(item) for item in scan_result.get("package_ids") or []] + detected_tag_ids = [int(tag) for tag in scan_result.get("visible_tag_ids") or []] + zone_id = str(scan_result.get("zone_id") or "") + source = str(scan_result.get("source") or "scan_zone") + return RouteActionResult( + ok=True, + note=( + f"QR scan used scan_zone {zone_id} via {source}: " + f"{len(detected_payloads)} package payloads" + ), + payload={ + "expected_payloads": expected, + "detected_payloads": detected_payloads, + "detected_tag_ids": detected_tag_ids, + "source": "scan_zone", + "scan_zone_source": source, + "zone_id": zone_id, + "scan_zone": scan_result, + }, + evidence=[ + { + "kind": "qr_detection", + "path": None, + "mime_type": "application/json", + "metadata": { + "source": "scan_zone", + "scan_zone_source": source, + "zone_id": zone_id, + "expected_payloads": expected, + "detected_payloads": detected_payloads, + "detected_tag_ids": detected_tag_ids, + "scan_zone": scan_result, + }, + } + ], + ) + + +def _execute_gemini_inspect_image( + action: EditableRouteAction, + *, + run_dir: Path, + route_run_id: str, + waypoint_id: str, + route_id: str | None, + target_id: str | None, + pose: dict[str, Any] | None, +) -> RouteActionResult: + from dimos.experimental.dogops.gemini_vision import inspect_images_with_gemini + from dimos.experimental.dogops.route_run_store import RouteRunStore + + store = RouteRunStore(run_dir) + current = store.image_evidence_for_route_run_waypoint( + route_run_id=route_run_id, + waypoint_id=waypoint_id, + ) + if current is None or not current.get("path"): + return RouteActionResult( + ok=False, + state="failed", + note="Gemini inspect needs a preceding capture_image action at this waypoint", + payload={ + "source": "gemini", + "status": "image_missing", + "waypoint_id": waypoint_id, + "recommendation": "add capture_image before gemini_inspect_image", + }, + ) + + require_baseline = bool(action.args.get("require_baseline", False)) + baseline_policy = str(action.args.get("baseline_policy") or "same_waypoint_latest_previous") + baseline = store.latest_image_evidence_for_waypoint( + waypoint_id=waypoint_id, + exclude_route_run_id=route_run_id, + route_id=route_id, + target_id=str(action.args.get("target") or target_id or ""), + pose=pose, + baseline_policy=baseline_policy, + ) + if baseline is None and require_baseline: + return RouteActionResult( + ok=False, + state="failed", + note="Gemini inspect required a baseline image but none was available", + payload={ + "source": "gemini", + "status": "baseline_missing", + "baseline_policy": baseline_policy, + "waypoint_id": waypoint_id, + }, + ) + + model = str(action.args.get("model") or "gemini-2.5-flash") + max_inline = int(action.args.get("max_image_bytes_inline") or 20_000_000) + prompt = str( + action.args.get("prompt") + or "Inspect this waypoint for physical changes, safety issues, package changes, and work-order evidence." + ) + baseline_path = baseline.get("path") if baseline else None + gemini = inspect_images_with_gemini( + current_image_path=str(current["path"]), + current_mime_type=str(current.get("mime_type") or ""), + current_evidence_id=str(current["evidence_id"]), + baseline_image_path=str(baseline_path) if baseline_path else None, + baseline_mime_type=str(baseline.get("mime_type") or "") if baseline else None, + baseline_evidence_id=str(baseline.get("evidence_id")) if baseline else None, + prompt=prompt, + model=model, + max_image_bytes_inline=max_inline, + route_context={ + "route_id": route_id, + "route_run_id": route_run_id, + "waypoint_id": waypoint_id, + "target_id": target_id, + "baseline_policy": baseline_policy, + "baseline_match": baseline.get("baseline_match") if baseline else None, + }, + ) + payload = { + "source": "gemini", + "status": gemini.status, + "model": model, + "current_evidence_id": current["evidence_id"], + "baseline_policy": baseline_policy, + "baseline_match": baseline.get("baseline_match") if baseline else "none", + "baseline_evidence_id": baseline.get("evidence_id") if baseline else None, + "baseline_route_run_id": baseline.get("route_run_id") if baseline else None, + "baseline_created_at": baseline.get("created_at") if baseline else None, + "waypoint_id": waypoint_id, + "action_id": action.id, + } + if not gemini.ok or gemini.inspection is None: + payload["message"] = gemini.message + return RouteActionResult( + ok=True, + state="skipped", + note=gemini.message, + payload=payload, + ) + + analysis_path = _write_gemini_analysis( + run_dir=run_dir, + route_run_id=route_run_id, + waypoint_id=waypoint_id, + action_id=action.id, + payload={ + "schema_version": 1, + "analysis": gemini.inspection.model_dump(mode="json"), + "metadata": payload, + }, + ) + analysis = gemini.inspection.model_dump(mode="json") + return RouteActionResult( + ok=True, + state="completed", + note=analysis.get("summary") or "Gemini image inspection completed", + payload={**payload, "analysis": analysis}, + evidence=[ + { + "kind": "gemini_vision_analysis", + "path": str(analysis_path), + "mime_type": "application/json", + "metadata": { + **payload, + "changed": analysis.get("changed"), + "summary": analysis.get("summary"), + "change_summary": analysis.get("change_summary"), + "severity": analysis.get("severity"), + "confidence": analysis.get("confidence"), + "possible_incident": analysis.get("possible_incident"), + }, + } + ], + ) + + +def _write_gemini_analysis( + *, + run_dir: Path, + route_run_id: str, + waypoint_id: str, + action_id: str, + payload: dict[str, Any], +) -> Path: + evidence_dir = run_dir / "route_runs" / route_run_id / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + path = evidence_dir / f"{waypoint_id}-{action_id}-gemini.json" + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def _capture_image_path( + *, + run_dir: Path, + route_run_id: str, + waypoint_id: str, + action: EditableRouteAction, + capture_image_handler: CaptureImageHandler | None = None, +) -> tuple[Path, str, str, dict[str, Any]]: + evidence_dir = run_dir / "route_runs" / route_run_id / "evidence" + if capture_image_handler is not None: + captured = capture_image_handler( + { + "evidence_dir": evidence_dir, + "route_run_id": route_run_id, + "waypoint_id": waypoint_id, + "action_id": action.id, + "action": action.model_dump(mode="json"), + } + ) + if captured: + return ( + Path(str(captured["path"])), + str(captured.get("source") or "go2_camera_live"), + str(captured.get("mime_type") or "image/png"), + dict(captured.get("metadata") or {}), + ) + configured = action.args.get("image_path") or os.environ.get("DOGOPS_GO2_CAMERA_IMAGE_PATH") + if configured: + source = Path(str(configured)).expanduser() + if source.exists() and source.is_file(): + evidence_dir.mkdir(parents=True, exist_ok=True) + suffix = source.suffix or ".img" + destination = evidence_dir / f"{waypoint_id}-{action.id}{suffix}" + shutil.copyfile(source, destination) + return destination, "go2_camera_configured", _mime_type_for_suffix(suffix), {} + return ( + _write_placeholder_image( + run_dir=run_dir, + route_run_id=route_run_id, + waypoint_id=waypoint_id, + action=action, + ), + "demo_placeholder", + "image/png", + {}, + ) + + +def _mime_type_for_suffix(suffix: str) -> str: + return { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".svg": "image/svg+xml", + }.get(suffix.lower(), "application/octet-stream") + + +def _write_placeholder_image( + *, + run_dir: Path, + route_run_id: str, + waypoint_id: str, + action: EditableRouteAction, +) -> Path: + evidence_dir = run_dir / "route_runs" / route_run_id / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + path = evidence_dir / f"{waypoint_id}-{action.id}.png" + label = f"target={action.args.get('target') or waypoint_id}; action={action.label or action.id}" + _write_placeholder_png(path, label=label) + return path + + +def _write_placeholder_png(path: Path, *, label: str) -> None: + width = 640 + height = 360 + label_seed = sum(label.encode("utf-8")) % 80 + rows = [] + for y in range(height): + row = bytearray() + for x in range(width): + aisle = abs(x - width // 2) < 58 + y // 8 + stripe = abs(x - (width // 2 - 95 - y // 9)) < 4 or abs(x - (width // 2 + 95 + y // 9)) < 4 + machine = (60 < x < 210 or 430 < x < 585) and 80 < y < 260 + if stripe: + color = (242, 196, 63) + elif aisle: + base = 90 + (y * 70 // height) + color = (base, base + 5, base + 10) + elif machine: + color = (55 + label_seed // 3, 75, 88) + else: + color = (27, 39 + label_seed // 8, 52) + row.extend(color) + rows.append(b"\x00" + bytes(row)) + raw = b"".join(rows) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + _png_chunk(b"tEXt", b"Description\x00DogOps demo placeholder camera frame") + + _png_chunk(b"IDAT", zlib.compress(raw, level=9)) + + _png_chunk(b"IEND", b"") + ) + + +def _png_chunk(kind: bytes, data: bytes) -> bytes: + checksum = zlib.crc32(kind + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum) diff --git a/dimos/experimental/dogops/route_executor.py b/dimos/experimental/dogops/route_executor.py new file mode 100644 index 0000000000..e3b6a8bb12 --- /dev/null +++ b/dimos/experimental/dogops/route_executor.py @@ -0,0 +1,876 @@ +from __future__ import annotations + +from collections.abc import Callable +import json +import math +import os +from pathlib import Path +import threading +import time +from typing import Any, Literal, Protocol + +from pydantic import Field, field_validator + +from dimos.experimental.dogops.live_map import LIVE_TOPIC_MAX_AGE_S +from dimos.experimental.dogops.map_authoring import ( + EditableRoute, + EditableRouteWaypoint, + load_map_authoring, +) +from dimos.experimental.dogops.models import DogOpsModel, NavAction, NavEvent +from dimos.experimental.dogops.nav_eval import summarize_nav_events +from dimos.experimental.dogops.route_actions import ( + CaptureImageHandler, + EditableRouteAction, + ScanZoneHandler, + execute_route_action, +) +from dimos.experimental.dogops.route_run_store import RouteRunStore, new_route_run_id +from dimos.experimental.dogops.store import DogOpsStore + + +ROUTE_EXECUTION_FILENAME = "route_execution.json" +ROUTE_EXECUTION_LOCK_FILENAME = "route_execution.lock" +GOAL_CONFIRM_RADIUS_M = 0.75 +PROGRESS_EPSILON_M = 0.05 +RouteExecutionEventState = Literal[ + "queued", + "sent", + "accepted", + "started", + "completed", + "reached", + "timeout", + "failed", + "skipped", + "stopped", +] + + +class RouteExecutionError(ValueError): + pass + + +class GoalPublisher(Protocol): + transport_name: str + + def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]: + ... + + +StopHandler = Callable[[], Any] + + +class CallableGoalPublisher: + def __init__( + self, + handler: Callable[[float, float, float, str], Any], + *, + transport_name: str = "handler", + ) -> None: + self._handler = handler + self.transport_name = transport_name + + def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]: + result = self._handler(x, y, z, frame_id) + return {"transport": self.transport_name, "result": result} + + +class ClickedPointGoalPublisher: + transport_name = "clicked_point" + + def __init__(self, publisher: Any, point_type: type[Any]) -> None: + self._publisher = publisher + self._point_type = point_type + + def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]: + if self._publisher is None or not hasattr(self._publisher, "publish"): + raise RouteExecutionError("DogOps follow_route needs the DimOS clicked_point stream.") + point = self._point_type(ts=time.time(), frame_id=frame_id, x=x, y=y, z=z) + self._publisher.publish(point) + return {"transport": self.transport_name} + + +class RouteExecutionEvent(DogOpsModel): + id: str + ts: float + route_id: str + waypoint_id: str + target_id: str | None = None + x: float + y: float + state: RouteExecutionEventState + elapsed_s: float = 0.0 + error_m: float | None = None + retries: int = 0 + guided: bool = False + note: str = "" + kind: Literal["route", "waypoint", "navigation", "action", "observation", "evidence", "incident", "system"] = "waypoint" + action_id: str | None = None + payload: dict[str, Any] = Field(default_factory=dict) + + +class RouteExecutionState(DogOpsModel): + run_id: str + route_run_id: str | None = None + route_id: str = "" + state: Literal["idle", "running", "paused", "completed", "failed", "stopped"] = "idle" + started_at: float | None = None + completed_at: float | None = None + active_waypoint_id: str | None = None + active_action_id: str | None = None + active_index: int = 0 + stop_requested: bool = False + frame: str = "map" + reach_radius_m: float = 0.35 + waypoint_timeout_s: float = 20.0 + max_retries: int = 1 + transport: str = "unconfigured" + last_error: str | None = None + waypoints_total: int = 0 + waypoints_reached: int = 0 + events: list[RouteExecutionEvent] = Field(default_factory=list) + + @field_validator("reach_radius_m", "waypoint_timeout_s") + @classmethod + def positive_float(cls, value: float) -> float: + result = float(value) + if not math.isfinite(result) or result <= 0: + raise ValueError("value must be a positive finite number") + return result + + +class DogOpsRouteExecutor: + def __init__( + self, + run_dir: str | Path, + *, + goal_publisher: GoalPublisher | None = None, + live_snapshot_reader: Callable[[], dict[str, Any]] | None = None, + stop_handler: StopHandler | None = None, + scan_zone_handler: ScanZoneHandler | None = None, + capture_image_handler: CaptureImageHandler | None = None, + frame: str = "map", + reach_radius_m: float = 0.35, + waypoint_timeout_s: float = 20.0, + max_retries: int = 1, + no_progress_timeout_s: float | None = None, + require_goal_confirmation: bool = True, + poll_interval_s: float = 0.2, + time_fn: Callable[[], float] = time.time, + sleep_fn: Callable[[float], None] = time.sleep, + ) -> None: + self.run_dir = Path(run_dir) + self.goal_publisher = goal_publisher + self.live_snapshot_reader = live_snapshot_reader + self.stop_handler = stop_handler + self.scan_zone_handler = scan_zone_handler + self.capture_image_handler = capture_image_handler + self.frame = frame or "map" + self.reach_radius_m = reach_radius_m + self.waypoint_timeout_s = waypoint_timeout_s + self.max_retries = max_retries + self.require_goal_confirmation = require_goal_confirmation + self.no_progress_timeout_s = ( + no_progress_timeout_s + if no_progress_timeout_s is not None + else min(5.0, max(1.0, waypoint_timeout_s / 2.0)) + ) + self.poll_interval_s = poll_interval_s + self.time_fn = time_fn + self.sleep_fn = sleep_fn + + def follow_route(self, route_id: str | None = None, *, dry_run: bool = False) -> RouteExecutionState: + with route_execution_lock(self.run_dir): + route, frame = self._resolve_route(route_id) + route = self._route_with_default_actions(route) + self._validate_route(route) + started_at = self.time_fn() + route_run_id = new_route_run_id(route.id, now=started_at) + state = RouteExecutionState( + run_id=self.run_id, + route_run_id=route_run_id, + route_id=route.id, + state="running", + started_at=started_at, + frame=frame, + reach_radius_m=self.reach_radius_m, + waypoint_timeout_s=self.waypoint_timeout_s, + max_retries=self.max_retries, + transport="dry_run" if dry_run else self._transport_name, + waypoints_total=len(route.waypoints), + ) + route_run_store = RouteRunStore(self.run_dir) + route_run_store.create_route_run( + route_run_id=route_run_id, + dogops_run_id=self.run_id, + route=route, + state=state, + dry_run=dry_run, + route_snapshot=route.model_dump(mode="json"), + ) + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + + for index, waypoint in enumerate(route.waypoints): + if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested: + state = self._stop_state(state, waypoint, started_at) + break + state.active_index = index + state.active_waypoint_id = waypoint.id + queued = self._event(state, waypoint, "queued", started_at, note="waypoint queued") + state.events.append(queued) + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + if dry_run: + state = self._execute_waypoint_actions(state, waypoint, started_at, route_run_store) + route_run_store.sync_execution_state(state) + if state.state in {"failed", "stopped"}: + break + continue + state = self._execute_waypoint(state, waypoint, started_at) + route_run_store.sync_execution_state(state) + if state.state in {"failed", "stopped"}: + break + state = self._execute_waypoint_actions(state, waypoint, started_at, route_run_store) + route_run_store.sync_execution_state(state) + if state.state in {"failed", "stopped"}: + break + + if dry_run: + if state.state == "running": + state.state = "completed" + state.active_waypoint_id = None + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + return state + + if state.state == "running": + state.state = "completed" + state.active_waypoint_id = None + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + self._append_nav_events(state) + return state + + def stop_route(self) -> RouteExecutionState: + state = request_route_stop(self.run_dir, run_id=self.run_id, now=self.time_fn) + if self.stop_handler is not None: + try: + self.stop_handler() + except Exception as exc: + state.last_error = f"route stop requested; stop handler failed: {exc}" + save_route_execution(self.run_dir, state) + elif state.state == "stopped": + state.last_error = "route stop requested; no navigation stop handler configured" + save_route_execution(self.run_dir, state) + RouteRunStore(self.run_dir).sync_execution_state(state) + self._append_nav_events(state) + return state + + def status(self) -> RouteExecutionState: + return load_route_execution(self.run_dir, run_id=self.run_id) + + @property + def run_id(self) -> str: + return self.run_dir.name + + @property + def _transport_name(self) -> str: + if self.goal_publisher is None: + return "unconfigured" + return getattr(self.goal_publisher, "transport_name", self.goal_publisher.__class__.__name__) + + def _resolve_route(self, route_id: str | None) -> tuple[EditableRoute, str]: + authoring = load_map_authoring(self.run_dir) + resolved_id = route_id or authoring.selected_route_id + if not resolved_id: + raise RouteExecutionError("no route_id supplied and no authored route is selected") + for route in authoring.routes: + if route.id == resolved_id: + return route, authoring.frame or self.frame + raise RouteExecutionError(f"unknown route_id: {resolved_id}") + + def _validate_route(self, route: EditableRoute) -> None: + if not route.waypoints: + raise RouteExecutionError(f"route {route.id} has no waypoints") + for waypoint in route.waypoints: + if not math.isfinite(waypoint.pose.x) or not math.isfinite(waypoint.pose.y): + raise RouteExecutionError(f"waypoint {waypoint.id} has invalid coordinates") + + def _route_with_default_actions(self, route: EditableRoute) -> EditableRoute: + state_path = self.run_dir / "state.json" + if not state_path.exists(): + return route + store = DogOpsStore.load_existing(self.run_dir) + state = store.state + assert state is not None + steps_by_target: dict[str, list[Any]] = {} + for step in state.mission.steps: + steps_by_target.setdefault(step.target_id, []).append(step) + route_with_actions = route.model_copy(deep=True) + for waypoint in route_with_actions.waypoints: + if waypoint.actions: + continue + target_id = waypoint.target_id or waypoint.id + actions = [] + for step in steps_by_target.get(target_id, []): + actions.extend(_mission_step_actions(state, step)) + waypoint.actions = actions + return route_with_actions + + def _execute_waypoint( + self, + state: RouteExecutionState, + waypoint: EditableRouteWaypoint, + started_at: float, + ) -> RouteExecutionState: + if self.goal_publisher is None: + state.state = "failed" + state.last_error = "navigation publisher unavailable" + state.events.append( + self._event(state, waypoint, "failed", started_at, note=state.last_error) + ) + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + return state + + best_error_m: float | None = None + for retry in range(self.max_retries + 1): + waypoint_started = self.time_fn() + try: + publish_result = self.goal_publisher.publish_goal( + x=waypoint.pose.x, + y=waypoint.pose.y, + z=0.0, + frame_id=state.frame, + ) + except Exception as exc: + state.state = "failed" + state.last_error = f"goal publish failed: {exc}" + state.events.append( + self._event( + state, + waypoint, + "failed", + started_at, + retries=retry, + note=state.last_error, + ) + ) + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + return state + + state.events.append( + self._event( + state, + waypoint, + "sent", + started_at, + retries=retry, + note=f"sent via {publish_result.get('transport') or self._transport_name}", + ) + ) + save_route_execution(self.run_dir, state) + + reached, error_m, note = self._wait_until_reached(waypoint, waypoint_started) + best_error_m = error_m if best_error_m is None else min(best_error_m, error_m or best_error_m) + if reached: + state.waypoints_reached += 1 + state.events.append( + self._event( + state, + waypoint, + "reached", + started_at, + retries=retry, + error_m=error_m, + note=note, + ) + ) + save_route_execution(self.run_dir, state) + return state + if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested: + return self._stop_state(state, waypoint, started_at) + + state.state = "failed" + state.last_error = note + state.events.append( + self._event( + state, + waypoint, + "timeout", + started_at, + retries=self.max_retries, + error_m=best_error_m, + note=note, + ) + ) + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + return state + + def _execute_waypoint_actions( + self, + state: RouteExecutionState, + waypoint: EditableRouteWaypoint, + started_at: float, + route_run_store: RouteRunStore, + ) -> RouteExecutionState: + for action in waypoint.actions: + if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested: + return self._stop_state(state, waypoint, started_at) + state.active_action_id = action.id + state.events.append( + self._action_event(state, waypoint, action, "started", started_at, note="action started") + ) + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + try: + result = execute_route_action( + action, + run_dir=self.run_dir, + route_run_id=state.route_run_id or "", + waypoint_id=waypoint.id, + route_id=state.route_id, + target_id=waypoint.target_id or waypoint.id, + pose=waypoint.pose.model_dump(mode="json"), + scan_zone_handler=self.scan_zone_handler, + capture_image_handler=self.capture_image_handler, + ) + result_ok = result.ok + result_note = result.note + result_payload = result.payload + result_evidence = result.evidence + except Exception as exc: + result_ok = False + result_note = f"action {action.id} failed: {exc}" + result_payload = {"error": exc.__class__.__name__, "source": "exception"} + result_evidence = [] + event_state: RouteExecutionEventState = result.state if result_ok else "failed" + state.events.append( + self._action_event( + state, + waypoint, + action, + event_state, + started_at, + note=result_note, + payload=result_payload, + ) + ) + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + action_event_id = f"{state.route_run_id}-{state.events[-1].id}" if state.route_run_id else state.events[-1].id + recorded_evidence: list[dict[str, Any]] = [] + for evidence in result_evidence: + recorded_evidence.append(route_run_store.record_evidence( + route_run_id=state.route_run_id or "", + event_id=action_event_id, + observation_id=evidence.get("observation_id"), + kind=str(evidence.get("kind") or "evidence"), + path=evidence.get("path"), + metadata=evidence.get("metadata") or {}, + mime_type=evidence.get("mime_type"), + )) + if recorded_evidence: + state.events[-1].payload["evidence"] = recorded_evidence + analysis_evidence = [ + item for item in recorded_evidence if item.get("kind") == "gemini_vision_analysis" + ] + if analysis_evidence: + state.events[-1].payload["analysis_evidence_id"] = analysis_evidence[0]["evidence_id"] + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + if not result_ok and action.required: + state.state = "failed" + state.last_error = result_note or f"required action failed: {action.id}" + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + route_run_store.sync_execution_state(state) + return state + state.active_action_id = None + save_route_execution(self.run_dir, state) + return state + + def _wait_until_reached( + self, + waypoint: EditableRouteWaypoint, + waypoint_started: float, + ) -> tuple[bool, float | None, str]: + if self.live_snapshot_reader is None: + return False, None, "no live odom reader configured" + best_error_m: float | None = None + last_progress_at = waypoint_started + goal_confirmed = False + note = "timeout waiting for odom" + while self.time_fn() - waypoint_started <= self.waypoint_timeout_s: + snapshot = self.live_snapshot_reader() + odom, odom_age_s = route_feedback_from_snapshot(snapshot) + goal_confirmed = ( + True + if not self.require_goal_confirmation + else goal_confirmed or route_goal_confirmed(snapshot, waypoint) + ) + if odom is None: + note = "no odom received" + elif odom_age_s is not None and odom_age_s > LIVE_TOPIC_MAX_AGE_S: + note = f"odom stale: {odom_age_s:.1f}s" + else: + error_m = math.hypot(odom["x"] - waypoint.pose.x, odom["y"] - waypoint.pose.y) + if best_error_m is None or error_m < best_error_m - PROGRESS_EPSILON_M: + best_error_m = error_m + last_progress_at = self.time_fn() + note = ( + f"odom error {error_m:.2f}m; " + f"goal {'confirmed' if goal_confirmed else 'unconfirmed'}" + ) + if error_m <= self.reach_radius_m and goal_confirmed: + return True, error_m, note + if self.time_fn() - last_progress_at >= self.no_progress_timeout_s: + return False, best_error_m, "no progress toward waypoint" + if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested: + return False, best_error_m, "stop requested" + self.sleep_fn(self.poll_interval_s) + return False, best_error_m, note + + def _event( + self, + state: RouteExecutionState, + waypoint: EditableRouteWaypoint, + event_state: RouteExecutionEventState, + started_at: float, + *, + retries: int = 0, + error_m: float | None = None, + note: str = "", + ) -> RouteExecutionEvent: + return RouteExecutionEvent( + id=f"RTE-{len(state.events) + 1:03d}", + ts=self.time_fn(), + route_id=state.route_id, + waypoint_id=waypoint.id, + target_id=waypoint.target_id or waypoint.id, + x=waypoint.pose.x, + y=waypoint.pose.y, + state=event_state, + elapsed_s=max(0.0, self.time_fn() - started_at), + error_m=error_m, + retries=retries, + guided=False, + note=note, + ) + + def _action_event( + self, + state: RouteExecutionState, + waypoint: EditableRouteWaypoint, + action: EditableRouteAction, + event_state: RouteExecutionEventState, + started_at: float, + *, + note: str = "", + payload: dict[str, Any] | None = None, + ) -> RouteExecutionEvent: + return RouteExecutionEvent( + id=f"RTE-{len(state.events) + 1:03d}", + ts=self.time_fn(), + route_id=state.route_id, + waypoint_id=waypoint.id, + target_id=waypoint.target_id or waypoint.id, + x=waypoint.pose.x, + y=waypoint.pose.y, + state=event_state, + elapsed_s=max(0.0, self.time_fn() - started_at), + guided=action.kind == "operator_prompt", + note=note, + kind="action", + action_id=action.id, + payload={"kind": action.kind, **(payload or {})}, + ) + + def _stop_state( + self, + state: RouteExecutionState, + waypoint: EditableRouteWaypoint, + started_at: float, + ) -> RouteExecutionState: + state.state = "stopped" + state.stop_requested = True + state.last_error = "stop requested" + state.events.append(self._event(state, waypoint, "stopped", started_at, note="stop requested")) + state.completed_at = self.time_fn() + save_route_execution(self.run_dir, state) + RouteRunStore(self.run_dir).sync_execution_state(state) + self._append_nav_events(state) + return state + + def _append_nav_events(self, route_state: RouteExecutionState) -> None: + state_path = self.run_dir / "state.json" + if not state_path.exists(): + return + store = DogOpsStore.load_existing(self.run_dir) + state = store.state + assert state is not None + existing_events = {(event.ts, event.note) for event in state.nav_events} + for event in route_state.events: + if event.state not in {"reached", "timeout", "failed", "stopped"}: + continue + note = ( + f"live route {route_state.route_id}: {event.state} " + f"{event.target_id or event.waypoint_id} via {route_state.transport}; {event.note}" + ) + existing_key = (event.ts, note) + if existing_key in existing_events: + continue + nav_event = NavEvent( + id=f"NAV-{len(state.nav_events) + 1:03d}", + run_id=state.run.id, + ts=event.ts, + action=NavAction.goto, + target_id=event.target_id, + success=event.state == "reached", + elapsed_s=event.elapsed_s, + retries=event.retries, + guided=event.guided, + error_m=event.error_m, + note=note, + ) + store.append_nav_event(nav_event) + existing_events.add(existing_key) + state.nav_summary = summarize_nav_events(state.run.id, state.nav_events) + store.write_state(state.run.id) + store.write_report(state.run.id) + + +def route_execution_path(run_dir: str | Path) -> Path: + return Path(run_dir) / ROUTE_EXECUTION_FILENAME + + +def route_execution_lock_path(run_dir: str | Path) -> Path: + return Path(run_dir) / ROUTE_EXECUTION_LOCK_FILENAME + + +class route_execution_lock: + def __init__(self, run_dir: str | Path) -> None: + self.run_dir = Path(run_dir) + self.path = route_execution_lock_path(run_dir) + + def __enter__(self) -> None: + self.run_dir.mkdir(parents=True, exist_ok=True) + try: + fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError as exc: + state = load_route_execution(self.run_dir) + if state.state == "running": + raise RouteExecutionError(f"route {state.route_id} is already running") from exc + self.path.unlink(missing_ok=True) + fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(f"{os.getpid()} {threading.get_ident()} {time.time()}\n") + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.path.unlink(missing_ok=True) + + +def load_route_execution(run_dir: str | Path, *, run_id: str | None = None) -> RouteExecutionState: + path = route_execution_path(run_dir) + if not path.exists(): + return RouteExecutionState(run_id=run_id or Path(run_dir).name) + payload = json.loads(path.read_text(encoding="utf-8")) + state = RouteExecutionState.model_validate(payload) + if run_id is not None and state.run_id != run_id: + raise RouteExecutionError(f"loaded route execution for {state.run_id}, expected {run_id}") + return state + + +def save_route_execution(run_dir: str | Path, state: RouteExecutionState) -> Path: + path = route_execution_path(run_dir) + path.parent.mkdir(parents=True, exist_ok=True) + raw = json.dumps(state.model_dump(mode="json"), indent=2, sort_keys=True) + tmp_path = path.with_name( + f"{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp" + ) + tmp_path.write_text(raw + "\n", encoding="utf-8") + tmp_path.replace(path) + return path + + +def route_goal_confirmed(snapshot: dict[str, Any], waypoint: EditableRouteWaypoint) -> bool: + for key in ("target", "goal_request", "clicked_point"): + pose = snapshot.get(key) + if _pose_matches_waypoint(pose, waypoint): + return True + path = snapshot.get("path") + return isinstance(path, list) and bool(path) + + +def request_route_stop( + run_dir: str | Path, + *, + run_id: str | None = None, + now: Callable[[], float] = time.time, +) -> RouteExecutionState: + state = load_route_execution(run_dir, run_id=run_id) + if state.state not in {"completed", "failed", "stopped"}: + state.stop_requested = True + state.state = "stopped" + state.completed_at = now() + _append_stop_event_to_state(state, now=now) + save_route_execution(run_dir, state) + RouteRunStore(run_dir).sync_execution_state(state) + return state + + +def _append_stop_event_to_state(state: RouteExecutionState, *, now: Callable[[], float]) -> None: + if state.state != "stopped" or any(event.state == "stopped" for event in state.events): + return + previous = state.events[-1] if state.events else None + if previous is None: + return + state.events.append( + RouteExecutionEvent( + id=f"RTE-{len(state.events) + 1:03d}", + ts=now(), + route_id=state.route_id, + waypoint_id=state.active_waypoint_id or previous.waypoint_id, + target_id=previous.target_id, + x=previous.x, + y=previous.y, + state="stopped", + elapsed_s=previous.elapsed_s, + retries=previous.retries, + guided=previous.guided, + note="stop requested", + kind="system", + ) + ) + + +def route_feedback_from_snapshot(snapshot: dict[str, Any]) -> tuple[dict[str, float] | None, float | None]: + pose = snapshot.get("robot_pose") or snapshot.get("pose") + if not isinstance(pose, dict): + return None, None + try: + x = float(pose["x"]) + y = float(pose["y"]) + except (KeyError, TypeError, ValueError): + return None, None + if not math.isfinite(x) or not math.isfinite(y): + return None, None + age_s = None + topics = snapshot.get("topics") + if isinstance(topics, dict): + odom = topics.get("odom") + if isinstance(odom, dict) and odom.get("age_s") is not None: + try: + age_s = float(odom["age_s"]) + except (TypeError, ValueError): + age_s = None + return {"x": x, "y": y}, age_s + + +def _mission_step_actions(state: Any, step: Any) -> list[EditableRouteAction]: + base_args = {"target_id": step.target_id, "mission_action": step.action} + sim_obs = state.mission.simulation_observations.get(step.id) + if step.action == "scan_zone": + visible_tags = list(sim_obs.visible_tag_ids) if sim_obs is not None else [] + qr_payloads = _qr_payloads_for_observation(sim_obs) + actions = [ + EditableRouteAction( + id=f"{step.id}_tags", + kind="scan_tags", + label="scan_tags", + required=step.required, + timeout_s=step.timeout_s, + args={**base_args, "expected": visible_tags}, + ) + ] + if qr_payloads: + actions.append( + EditableRouteAction( + id=f"{step.id}_qr", + kind="scan_qr", + label="scan_qr", + required=False, + timeout_s=step.timeout_s, + args={**base_args, "expected": qr_payloads}, + ) + ) + return actions + if step.action == "inspect_asset": + return [ + EditableRouteAction( + id=f"{step.id}_image", + kind="capture_image", + label="capture_image", + required=False, + timeout_s=step.timeout_s, + args={**base_args, "target": step.target_id}, + ), + EditableRouteAction( + id=step.id, + kind="inspect_asset", + label=step.action, + required=step.required, + timeout_s=step.timeout_s, + args=base_args, + ), + ] + if step.action == "verify_work_order": + return [ + EditableRouteAction( + id=f"{step.id}_image", + kind="capture_image", + label="capture_image", + required=False, + timeout_s=step.timeout_s, + args={**base_args, "target": step.target_id}, + ), + EditableRouteAction( + id=step.id, + kind="verify_work_order", + label=step.action, + required=step.required, + timeout_s=step.timeout_s, + args=base_args, + ), + ] + if step.action == "wait_for_human_fix": + return [ + EditableRouteAction( + id=step.id, + kind="operator_prompt", + label=step.action, + required=step.required, + timeout_s=step.timeout_s, + args=base_args, + ) + ] + return [] + + +def _qr_payloads_for_observation(sim_obs: Any) -> list[str]: + if sim_obs is None: + return [] + payloads: list[str] = [] + for key in sim_obs.facts: + if key.startswith("PKG-") and key.endswith(".zone_id"): + payloads.append(key.removesuffix(".zone_id")) + return sorted(set(payloads)) + + +def _pose_matches_waypoint(pose: Any, waypoint: EditableRouteWaypoint) -> bool: + if not isinstance(pose, dict): + return False + try: + x = float(pose["x"]) + y = float(pose["y"]) + except (KeyError, TypeError, ValueError): + return False + if not math.isfinite(x) or not math.isfinite(y): + return False + return math.hypot(x - waypoint.pose.x, y - waypoint.pose.y) <= GOAL_CONFIRM_RADIUS_M diff --git a/dimos/experimental/dogops/route_run_store.py b/dimos/experimental/dogops/route_run_store.py new file mode 100644 index 0000000000..4d64fb5f84 --- /dev/null +++ b/dimos/experimental/dogops/route_run_store.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +from pathlib import Path +import json +import sqlite3 +import time +from typing import Any +import uuid + + +ROUTE_RUN_DB_FILENAME = "dogops.sqlite" + + +def dogops_history_root(run_dir: str | Path) -> Path: + path = Path(run_dir).resolve() + if path.parent.name == "runs" and path.parent.parent.name == ".dogops": + return path.parent.parent + return path.parent + + +def route_run_db_path(run_dir: str | Path) -> Path: + return dogops_history_root(run_dir) / ROUTE_RUN_DB_FILENAME + + +def new_route_run_id(route_id: str, *, now: float | None = None) -> str: + timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(now or time.time())) + suffix = uuid.uuid4().hex[:8] + slug = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in route_id).strip("-") + return f"RR-{timestamp}-{slug or 'route'}-{suffix}" + + +class RouteRunStore: + def __init__(self, run_dir: str | Path, *, db_path: str | Path | None = None) -> None: + self.run_dir = Path(run_dir) + self.db_path = Path(db_path) if db_path is not None else route_run_db_path(self.run_dir) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def create_route_run( + self, + *, + route_run_id: str, + dogops_run_id: str, + route: Any, + state: Any, + dry_run: bool, + route_snapshot: dict[str, Any], + ) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO route_runs ( + route_run_id, dogops_run_id, run_dir, route_id, route_label, mission_id, + state, started_at, completed_at, dry_run, transport, frame, + selected_route_snapshot_json, active_waypoint_id, active_action_id, + waypoints_total, waypoints_reached, actions_total, actions_completed, + last_error, summary + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + route_run_id, + dogops_run_id, + str(self.run_dir), + route.id, + route.label, + route.mission_id, + state.state, + state.started_at, + state.completed_at, + 1 if dry_run else 0, + state.transport, + state.frame, + json.dumps(route_snapshot, sort_keys=True), + state.active_waypoint_id, + getattr(state, "active_action_id", None), + state.waypoints_total, + state.waypoints_reached, + _route_action_count(route_snapshot), + 0, + state.last_error, + "", + ), + ) + + def sync_execution_state(self, state: Any) -> None: + route_run_id = getattr(state, "route_run_id", None) + if not route_run_id: + return + events = [event.model_dump(mode="json") for event in state.events] + actions_completed = sum( + 1 for event in events if event.get("kind") == "action" and event.get("state") == "completed" + ) + with self._connect() as conn: + conn.execute( + """ + UPDATE route_runs + SET state = ?, completed_at = ?, active_waypoint_id = ?, active_action_id = ?, + waypoints_total = ?, waypoints_reached = ?, actions_completed = ?, + last_error = ?, summary = ? + WHERE route_run_id = ? + """, + ( + state.state, + state.completed_at, + state.active_waypoint_id, + getattr(state, "active_action_id", None), + state.waypoints_total, + state.waypoints_reached, + actions_completed, + state.last_error, + _summary_for_state(state), + route_run_id, + ), + ) + for sequence, event in enumerate(events, 1): + event_id = f"{route_run_id}-{event['id']}" + conn.execute( + """ + INSERT OR REPLACE INTO route_run_events ( + event_id, route_run_id, ts, sequence, kind, state, waypoint_id, + action_id, target_id, x, y, error_m, retries, guided, payload_json, note + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + route_run_id, + event["ts"], + sequence, + event.get("kind") or "waypoint", + event["state"], + event.get("waypoint_id"), + event.get("action_id"), + event.get("target_id"), + event.get("x"), + event.get("y"), + event.get("error_m"), + event.get("retries") or 0, + 1 if event.get("guided") else 0, + json.dumps(event.get("payload") or {}, sort_keys=True), + event.get("note") or "", + ), + ) + self._write_route_run_exports(route_run_id, state) + + def record_evidence( + self, + *, + route_run_id: str, + event_id: str | None, + observation_id: str | None, + kind: str, + path: str | Path | None, + metadata: dict[str, Any] | None = None, + mime_type: str | None = None, + ) -> dict[str, Any]: + evidence_id = f"EVD-{uuid.uuid4().hex[:10]}" + created_at = time.time() + evidence_path = str(path) if path is not None else None + with self._connect() as conn: + conn.execute( + """ + INSERT INTO route_run_evidence ( + evidence_id, route_run_id, event_id, observation_id, kind, path, uri, + sha256, mime_type, metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + evidence_id, + route_run_id, + event_id, + observation_id, + kind, + evidence_path, + None, + None, + mime_type, + json.dumps(metadata or {}, sort_keys=True), + created_at, + ), + ) + self._append_evidence_export(route_run_id, { + "evidence_id": evidence_id, + "route_run_id": route_run_id, + "event_id": event_id, + "observation_id": observation_id, + "kind": kind, + "path": evidence_path, + "mime_type": mime_type, + "metadata": metadata or {}, + "created_at": created_at, + }) + return { + "evidence_id": evidence_id, + "route_run_id": route_run_id, + "event_id": event_id, + "observation_id": observation_id, + "kind": kind, + "path": evidence_path, + "mime_type": mime_type, + "metadata": metadata or {}, + "created_at": created_at, + } + + def replace_timeline_events( + self, + dogops_run_id: str, + rows: list[dict[str, Any]], + *, + route_run_id: str | None = None, + ) -> None: + with self._connect() as conn: + if route_run_id is None: + conn.execute("DELETE FROM dogops_timeline_events WHERE dogops_run_id = ?", (dogops_run_id,)) + else: + conn.execute( + "DELETE FROM dogops_timeline_events WHERE dogops_run_id = ? AND route_run_id = ?", + (dogops_run_id, route_run_id), + ) + for sequence, row in enumerate(rows, 1): + row_route_run_id = row.get("route_run_id") or route_run_id or "" + source_event_id = str(row.get("event_id") or f"TL-{sequence:04d}") + event_id = f"{dogops_run_id}:{row_route_run_id or 'run'}:{source_event_id}" + conn.execute( + """ + INSERT OR REPLACE INTO dogops_timeline_events ( + event_id, dogops_run_id, route_run_id, ts, sequence, kind, state, + target_id, note, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + dogops_run_id, + row_route_run_id or None, + float(row.get("ts") or 0.0), + int(row.get("sequence") or sequence), + str(row.get("kind") or "system"), + str(row.get("state") or ""), + row.get("target_id"), + str(row.get("note") or ""), + json.dumps(row.get("payload") or {}, sort_keys=True), + ), + ) + + def timeline_events( + self, + *, + dogops_run_id: str | None = None, + route_run_id: str | None = None, + ) -> list[dict[str, Any]]: + filters = [] + args: list[Any] = [] + if dogops_run_id is not None: + filters.append("dogops_run_id = ?") + args.append(dogops_run_id) + if route_run_id is not None: + filters.append("(route_run_id = ? OR route_run_id IS NULL)") + args.append(route_run_id) + where = f"WHERE {' AND '.join(filters)}" if filters else "" + with self._connect() as conn: + rows = conn.execute( + f""" + SELECT * FROM dogops_timeline_events + {where} + ORDER BY ts ASC, sequence ASC, event_id ASC + """, + args, + ).fetchall() + return [_row_to_dict(row) for row in rows] + + def list_route_runs(self, *, limit: int = 50) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM route_runs + ORDER BY started_at DESC, route_run_id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_row_to_dict(row) for row in rows] + + def current_route_run(self) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM route_runs + WHERE dogops_run_id = ? + ORDER BY + CASE state WHEN 'running' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END, + started_at DESC + LIMIT 1 + """, + (self.run_dir.name,), + ).fetchone() + return _row_to_dict(row) if row is not None else None + + def route_run_detail(self, route_run_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM route_runs WHERE route_run_id = ?", + (route_run_id,), + ).fetchone() + return _row_to_dict(row) if row is not None else None + + def route_run_events(self, route_run_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM route_run_events + WHERE route_run_id = ? + ORDER BY sequence ASC + """, + (route_run_id,), + ).fetchall() + return [_row_to_dict(row) for row in rows] + + def route_run_evidence(self, route_run_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM route_run_evidence + WHERE route_run_id = ? + ORDER BY created_at ASC + """, + (route_run_id,), + ).fetchall() + return [_row_to_dict(row) for row in rows] + + def image_evidence_for_route_run_waypoint( + self, + *, + route_run_id: str, + waypoint_id: str, + ) -> dict[str, Any] | None: + rows = [ + row + for row in self._image_evidence_rows() + if row["route_run_id"] == route_run_id + and row.get("metadata", {}).get("waypoint_id") == waypoint_id + ] + if not rows: + return None + return max(rows, key=lambda row: float(row.get("created_at") or 0.0)) + + def latest_image_evidence_for_waypoint( + self, + *, + waypoint_id: str, + exclude_route_run_id: str, + route_id: str | None = None, + target_id: str | None = None, + pose: dict[str, Any] | None = None, + pose_tolerance_m: float = 0.3, + baseline_policy: str = "same_waypoint_latest_previous", + ) -> dict[str, Any] | None: + current_started_at = self._route_run_started_at(exclude_route_run_id) + rows = [ + row + for row in self._image_evidence_rows() + if row["route_run_id"] != exclude_route_run_id + and ( + current_started_at is None + or float(row.get("started_at") or 0.0) < current_started_at + ) + ] + same_waypoint = [ + row for row in rows if row.get("metadata", {}).get("waypoint_id") == waypoint_id + ] + if same_waypoint: + same_route_waypoint = [ + row for row in same_waypoint if route_id and row.get("route_id") == route_id + ] + result = _select_same_waypoint_baseline( + same_route_waypoint or same_waypoint, + exclude_route_run_id=exclude_route_run_id, + route_id=route_id, + baseline_policy=baseline_policy, + current_started_at=current_started_at, + ) + if not result.get("baseline_match"): + result["baseline_match"] = ( + "same_route_waypoint" + if route_id and result.get("route_id") == route_id + else "same_waypoint" + ) + return result + + if target_id: + same_target = [ + row for row in rows if row.get("metadata", {}).get("target") == target_id + ] + if same_target: + result = max(same_target, key=_evidence_sort_key) + result["baseline_match"] = "same_target" + return result + + if pose: + nearest = _nearest_pose_evidence(rows, pose=pose, tolerance_m=pose_tolerance_m) + if nearest is not None: + nearest["baseline_match"] = "nearest_pose" + return nearest + return None + + def _image_evidence_rows(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT route_run_evidence.*, route_runs.route_id, route_runs.started_at + FROM route_run_evidence + JOIN route_runs ON route_runs.route_run_id = route_run_evidence.route_run_id + WHERE route_run_evidence.kind = 'image' + ORDER BY route_runs.started_at DESC, route_run_evidence.created_at DESC + """ + ).fetchall() + return [_row_to_dict(row) for row in rows] + + def _route_run_started_at(self, route_run_id: str) -> float | None: + with self._connect() as conn: + row = conn.execute( + "SELECT started_at FROM route_runs WHERE route_run_id = ?", + (route_run_id,), + ).fetchone() + if row is None: + return None + try: + return float(row["started_at"]) + except (KeyError, TypeError, ValueError): + return None + + def _write_route_run_exports(self, route_run_id: str, state: Any) -> None: + run_dir = self.run_dir / "route_runs" / route_run_id + run_dir.mkdir(parents=True, exist_ok=True) + run_payload = state.model_dump(mode="json") + (run_dir / "route_run.json").write_text( + json.dumps(run_payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with (run_dir / "events.jsonl").open("w", encoding="utf-8") as handle: + for event in run_payload.get("events") or []: + handle.write(json.dumps(event, sort_keys=True) + "\n") + (run_dir / "evidence").mkdir(exist_ok=True) + + def _append_evidence_export(self, route_run_id: str, payload: dict[str, Any]) -> None: + run_dir = self.run_dir / "route_runs" / route_run_id + run_dir.mkdir(parents=True, exist_ok=True) + with (run_dir / "evidence.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _ensure_schema(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS route_runs ( + route_run_id TEXT PRIMARY KEY, + dogops_run_id TEXT NOT NULL, + run_dir TEXT NOT NULL, + route_id TEXT NOT NULL, + route_label TEXT, + mission_id TEXT, + state TEXT NOT NULL, + started_at REAL NOT NULL, + completed_at REAL, + operator_id TEXT, + dry_run INTEGER NOT NULL DEFAULT 0, + transport TEXT NOT NULL, + frame TEXT NOT NULL, + selected_route_snapshot_json TEXT NOT NULL, + active_waypoint_id TEXT, + active_action_id TEXT, + waypoints_total INTEGER NOT NULL DEFAULT 0, + waypoints_reached INTEGER NOT NULL DEFAULT 0, + actions_total INTEGER NOT NULL DEFAULT 0, + actions_completed INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + summary TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS route_run_events ( + event_id TEXT PRIMARY KEY, + route_run_id TEXT NOT NULL, + ts REAL NOT NULL, + sequence INTEGER NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + waypoint_id TEXT, + action_id TEXT, + target_id TEXT, + x REAL, + y REAL, + error_m REAL, + retries INTEGER NOT NULL DEFAULT 0, + guided INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + note TEXT NOT NULL DEFAULT '', + FOREIGN KEY(route_run_id) REFERENCES route_runs(route_run_id) + ); + + CREATE TABLE IF NOT EXISTS route_run_evidence ( + evidence_id TEXT PRIMARY KEY, + route_run_id TEXT NOT NULL, + event_id TEXT, + observation_id TEXT, + kind TEXT NOT NULL, + path TEXT, + uri TEXT, + sha256 TEXT, + mime_type TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + FOREIGN KEY(route_run_id) REFERENCES route_runs(route_run_id), + FOREIGN KEY(event_id) REFERENCES route_run_events(event_id) + ); + + CREATE TABLE IF NOT EXISTS dogops_timeline_events ( + event_id TEXT PRIMARY KEY, + dogops_run_id TEXT NOT NULL, + route_run_id TEXT, + ts REAL NOT NULL, + sequence INTEGER NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + target_id TEXT, + note TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE INDEX IF NOT EXISTS idx_route_runs_started_at + ON route_runs(started_at DESC); + CREATE INDEX IF NOT EXISTS idx_route_runs_dogops_run_id + ON route_runs(dogops_run_id, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_route_runs_route_id + ON route_runs(route_id, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_route_run_events_run_sequence + ON route_run_events(route_run_id, sequence); + CREATE INDEX IF NOT EXISTS idx_route_run_evidence_run + ON route_run_evidence(route_run_id, created_at); + CREATE INDEX IF NOT EXISTS idx_dogops_timeline_run + ON dogops_timeline_events(dogops_run_id, ts, sequence); + CREATE INDEX IF NOT EXISTS idx_dogops_timeline_route_run + ON dogops_timeline_events(route_run_id, ts, sequence); + """ + ) + + +def _route_action_count(route_snapshot: dict[str, Any]) -> int: + return sum(len(waypoint.get("actions") or []) for waypoint in route_snapshot.get("waypoints") or []) + + +def _summary_for_state(state: Any) -> str: + return ( + f"{state.state}: {state.waypoints_reached}/{state.waypoints_total} waypoints" + + (f"; {state.last_error}" if state.last_error else "") + ) + + +def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any]: + if row is None: + return {} + result = dict(row) + for key in ("selected_route_snapshot_json", "payload_json", "metadata_json"): + if key in result: + raw = result.pop(key) + target = key.removesuffix("_json") + try: + result[target] = json.loads(raw or "{}") + except json.JSONDecodeError: + result[target] = {} + return result + + +def _evidence_sort_key(row: dict[str, Any]) -> tuple[float, float, str]: + return ( + float(row.get("started_at") or 0.0), + float(row.get("created_at") or 0.0), + str(row.get("evidence_id") or ""), + ) + + +def _nearest_pose_evidence( + rows: list[dict[str, Any]], + *, + pose: dict[str, Any], + tolerance_m: float, +) -> dict[str, Any] | None: + try: + x = float(pose["x"]) + y = float(pose["y"]) + except (KeyError, TypeError, ValueError): + return None + candidates: list[tuple[float, dict[str, Any]]] = [] + for row in rows: + metadata_pose = row.get("metadata", {}).get("pose") + if not isinstance(metadata_pose, dict): + continue + try: + dx = float(metadata_pose["x"]) - x + dy = float(metadata_pose["y"]) - y + except (KeyError, TypeError, ValueError): + continue + distance = (dx * dx + dy * dy) ** 0.5 + if distance <= tolerance_m: + candidates.append((distance, row)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], -_evidence_sort_key(item[1])[0])) + return candidates[0][1] + + +def _select_same_waypoint_baseline( + rows: list[dict[str, Any]], + *, + exclude_route_run_id: str, + route_id: str | None, + baseline_policy: str, + current_started_at: float | None, +) -> dict[str, Any]: + if baseline_policy in {"yesterday", "previous_calendar_day"} and current_started_at is not None: + previous_day = time.localtime(current_started_at - 24 * 60 * 60) + previous_day_rows = [ + row + for row in rows + if _same_local_day(float(row.get("started_at") or 0.0), previous_day) + ] + if previous_day_rows: + result = max(previous_day_rows, key=_evidence_sort_key) + result["baseline_match"] = ( + "previous_day_same_route_waypoint" + if route_id and result.get("route_id") == route_id + else "previous_day_same_waypoint" + ) + return result + + window_start = current_started_at - 36 * 60 * 60 + window_end = current_started_at - 12 * 60 * 60 + window_rows = [ + row + for row in rows + if window_start <= float(row.get("started_at") or 0.0) <= window_end + ] + if window_rows: + result = max(window_rows, key=_evidence_sort_key) + result["baseline_match"] = ( + "previous_day_window_same_route_waypoint" + if route_id and result.get("route_id") == route_id + else "previous_day_window_same_waypoint" + ) + return result + + result = max(rows, key=_evidence_sort_key) + if baseline_policy in {"yesterday", "previous_calendar_day"}: + result["baseline_match"] = "latest_previous_same_waypoint" + return result + + +def _same_local_day(timestamp: float, target_day: time.struct_time) -> bool: + candidate = time.localtime(timestamp) + return ( + candidate.tm_year == target_day.tm_year + and candidate.tm_yday == target_day.tm_yday + ) diff --git a/dimos/experimental/dogops/skills.py b/dimos/experimental/dogops/skills.py new file mode 100644 index 0000000000..03ee8a76f6 --- /dev/null +++ b/dimos/experimental/dogops/skills.py @@ -0,0 +1,1296 @@ +from __future__ import annotations + +from collections.abc import Callable +import json +import math +from pathlib import Path +import struct +import time +from typing import Any, TypeVar +import zlib + +from dimos.experimental.dogops.config_loader import ( + DEFAULT_MANIFEST, + DEFAULT_MISSION, + DEFAULT_POLICY, + DEFAULT_SITE, + load_manifest as read_manifest, + load_mission as read_mission, + load_site_config as read_site_config, +) +from dimos.experimental.dogops.detector import DetectedTag, DogOpsTagDetector +from dimos.experimental.dogops.heatmap_runs import gather_heatmap_run +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.models import ( + Asset, + DogOpsState, + Incident, + IncidentState, + IncidentType, + MissionState, + Observation, + PackageState, + Severity, + WorkOrder, + WorkOrderState, +) +from dimos.experimental.dogops.report import build_report_data +from dimos.experimental.dogops.live_map import DogOpsLiveMapAdapter +from dimos.experimental.dogops.route_executor import ( + CallableGoalPublisher, + ClickedPointGoalPublisher, + DogOpsRouteExecutor, + RouteExecutionError, +) +from dimos.experimental.dogops.store import DogOpsStore + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from reactivex.disposable import Disposable as RxDisposable +except ModuleNotFoundError: # pragma: no cover - local project-pack fallback. + RxDisposable = None + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.agents.annotation import skill +except ModuleNotFoundError: # pragma: no cover - fallback behavior is tested through methods. + F = TypeVar("F", bound=Callable[..., Any]) + + def skill(func: F | None = None, **metadata: object) -> F | Callable[[F], F]: + def decorate(inner: F) -> F: + inner.__dogops_skill__ = True # type: ignore[attr-defined] + inner.__dogops_skill_metadata__ = metadata # type: ignore[attr-defined] + return inner + + if func is None: + return decorate + return decorate(func) + + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.module import Module +except ModuleNotFoundError: + + class Module: + def __init__(self, **_: object) -> None: + pass + + @classmethod + def blueprint(cls, **kwargs: object) -> dict[str, object]: + return {"module": cls.__name__, "kwargs": kwargs} + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + +try: # pragma: no cover - exercised only inside a full DimOS checkout. + from dimos.core.stream import In, Out + from dimos.msgs.geometry_msgs.PointStamped import PointStamped + from dimos.msgs.sensor_msgs.Image import Image +except ModuleNotFoundError: # pragma: no cover - local project-pack fallback. + + class In: + def __class_getitem__(cls, _: object) -> type[In]: + return cls + + class Out: + def __class_getitem__(cls, _: object) -> type[Out]: + return cls + + class PointStamped: + def __init__( + self, + *, + ts: float, + frame_id: str, + x: float, + y: float, + z: float, + ) -> None: + self.ts = ts + self.frame_id = frame_id + self.x = x + self.y = y + self.z = z + + Image = Any # type: ignore[misc, assignment] + + +class DogOpsSkillContainer(Module): + """Deterministic SiteOps skills exposed through DimOS MCP or direct tests.""" + + color_image: In[Image] + clicked_point: Out[PointStamped] + + def __init__( + self, + *, + site_path: str | Path = DEFAULT_SITE, + manifest_path: str | Path = DEFAULT_MANIFEST, + mission_path: str | Path = DEFAULT_MISSION, + policy_path: str | Path = DEFAULT_POLICY, + run_dir: str | Path = ".dogops/runs/latest", + go_to_handler: Callable[[float, float, float, str], object] | None = None, + route_stop_handler: Callable[[], object] | None = None, + live_map_adapter: DogOpsLiveMapAdapter | None = None, + runtime_module: bool = False, + **_: object, + ) -> None: + if runtime_module or _: + super().__init__(**_) + self.site_path = Path(site_path) + self.manifest_path = Path(manifest_path) + self.mission_path = Path(mission_path) + self.policy_path = Path(policy_path) + self.run_dir = Path(run_dir) + self._go_to_handler = go_to_handler + self._route_stop_handler = route_stop_handler + self._live_map_adapter = live_map_adapter + self._latest_camera_image: object | None = None + self._latest_camera_received_at: float | None = None + + def start(self) -> None: # pragma: no cover - lifecycle exercised in full DimOS. + super().start() + color_image = getattr(self, "color_image", None) + if color_image is not None and hasattr(color_image, "subscribe"): + subscription = color_image.subscribe(self.ingest_camera_image) + _register_subscription(self, subscription) + + def ingest_camera_image(self, image: object) -> None: + self._latest_camera_image = image + self._latest_camera_received_at = time.time() + + @skill + def load_site_config(self, path: str = str(DEFAULT_SITE)) -> str: + """Load a DogOps site configuration file and report inventory counts.""" + self.site_path = Path(path) + site = read_site_config(self.site_path) + return _json( + ok=True, + skill="load_site_config", + site_id=site.site_id, + zones=len(site.zones), + assets=len(site.assets), + packages=len(site.packages), + ) + + @skill + def load_manifest(self, path: str = str(DEFAULT_MANIFEST)) -> str: + """Load a receiving manifest file and report the package count.""" + self.manifest_path = Path(path) + manifest = read_manifest(self.manifest_path) + return _json( + ok=True, + skill="load_manifest", + manifest_id=manifest.manifest_id, + packages=len(manifest.items), + ) + + @skill + def load_mission(self, path: str = str(DEFAULT_MISSION)) -> str: + """Load a DogOps mission plan and report its step count.""" + self.mission_path = Path(path) + mission = read_mission(self.mission_path) + return _json( + ok=True, + skill="load_mission", + mission_id=mission.mission_id, + steps=len(mission.steps), + ) + + @skill + def run_mission(self, mission_id: str = "receiving_sre_demo") -> str: + """Run the deterministic DogOps mission simulation.""" + mission = read_mission(self.mission_path) + if mission.mission_id != mission_id: + return _json( + ok=False, + skill="run_mission", + error="unknown_mission", + mission_id=mission_id, + configured_mission_id=mission.mission_id, + ) + state = run_offline_simulation( + site=self.site_path, + manifest=self.manifest_path, + mission=self.mission_path, + policy=self.policy_path, + out=self.run_dir, + ) + return _json( + ok=True, + skill="run_mission", + run_id=state.run.id, + mission_id=state.run.mission_id, + state=state.run.state, + report=str(self.run_dir / "report.md"), + summary=state.run.summary, + ) + + @skill + def go_to(self, x: float, y: float, z: float = 0.0, frame_id: str = "map") -> str: + """Send a navigation target through the DimOS clicked point stream.""" + try: + x_f, y_f, z_f = _finite_point(x, y, z) + except ValueError as exc: + return _json( + ok=False, + skill="go_to", + error="invalid_go_to_target", + message=str(exc), + ) + frame = frame_id.strip() or "map" + if self._go_to_handler is not None: + result = self._go_to_handler(x_f, y_f, z_f, frame) + return _json( + ok=True, + skill="go_to", + transport="handler", + x=x_f, + y=y_f, + z=z_f, + frame_id=frame, + result=result, + ) + + publisher = getattr(self, "clicked_point", None) + if publisher is None or not hasattr(publisher, "publish"): + return _json( + ok=False, + skill="go_to", + error="navigation_stream_unavailable", + message="DogOps go_to needs the DimOS clicked_point stream.", + x=x_f, + y=y_f, + z=z_f, + frame_id=frame, + ) + + point = PointStamped(ts=time.time(), frame_id=frame, x=x_f, y=y_f, z=z_f) + publisher.publish(point) + return _json( + ok=True, + skill="go_to", + transport="clicked_point", + x=x_f, + y=y_f, + z=z_f, + frame_id=frame, + ) + + @skill + def follow_route(self, route_id: str | None = None, dry_run: bool = False) -> str: + """Follow the selected authored DogOps route waypoint by waypoint.""" + publisher = self._route_goal_publisher() + if not dry_run and publisher is None: + return _json( + ok=False, + skill="follow_route", + error="navigation_stream_unavailable", + message="DogOps follow_route needs the DimOS clicked_point stream or a handler.", + ) + live_adapter = self._live_map_adapter or DogOpsLiveMapAdapter() + executor = DogOpsRouteExecutor( + self.run_dir, + goal_publisher=publisher, + stop_handler=self._route_stop_handler, + scan_zone_handler=self.scan_zone, + capture_image_handler=self._capture_latest_camera_image, + live_snapshot_reader=live_adapter.snapshot if not dry_run else None, + ) + try: + state = executor.follow_route(route_id, dry_run=dry_run) + except RouteExecutionError as exc: + return _json( + ok=False, + skill="follow_route", + error="route_execution_rejected", + message=str(exc), + ) + return _json( + ok=state.state == "completed", + skill="follow_route", + route_id=state.route_id, + state=state.state, + active_waypoint_id=state.active_waypoint_id, + waypoints_total=state.waypoints_total, + waypoints_reached=state.waypoints_reached, + transport=state.transport, + dry_run=dry_run, + last_error=state.last_error, + route_execution=state.model_dump(mode="json"), + ) + + @skill + def stop_route(self) -> str: + """Request that a running DogOps authored route stop.""" + executor = DogOpsRouteExecutor(self.run_dir, stop_handler=self._route_stop_handler) + try: + state = executor.stop_route() + except RouteExecutionError as exc: + return _json( + ok=False, + skill="stop_route", + error="route_execution_rejected", + message=str(exc), + ) + return _json( + ok=state.last_error is None, + skill="stop_route", + route_id=state.route_id, + state=state.state, + active_waypoint_id=state.active_waypoint_id, + last_error=state.last_error, + route_execution=state.model_dump(mode="json"), + ) + + @skill + def route_status(self) -> str: + """Return the current DogOps authored-route execution state.""" + executor = DogOpsRouteExecutor(self.run_dir) + state = executor.status() + return _json( + ok=True, + skill="route_status", + route_id=state.route_id, + state=state.state, + active_waypoint_id=state.active_waypoint_id, + waypoints_total=state.waypoints_total, + waypoints_reached=state.waypoints_reached, + transport=state.transport, + last_error=state.last_error, + route_execution=state.model_dump(mode="json"), + ) + + @skill + def gather_heatmap(self, area_id: str = "", duration_s: float = 0.0) -> str: + """Snapshot the current DimOS costmap as a DogOps gather-heatmap run.""" + live_adapter = self._live_map_adapter or DogOpsLiveMapAdapter() + result = gather_heatmap_run( + self.run_dir, + live_snapshot=live_adapter.snapshot(), + live_snapshot_reader=live_adapter.snapshot, + area_id=area_id, + duration_s=duration_s, + ) + return _json(skill="gather_heatmap", **result) + + @skill + def scan_zone(self, zone_id: str) -> str: + """Scan a zone using the live camera when available, otherwise deterministic fixtures.""" + camera_result = self._scan_zone_with_latest_camera(zone_id) + if camera_result is not None: + return camera_result + + mission = read_mission(self.mission_path) + observations = [ + obs for obs in mission.simulation_observations.values() if obs.zone_id == zone_id + ] + if not observations: + return _json(ok=False, skill="scan_zone", error="unknown_zone", zone_id=zone_id) + visible_tag_ids = sorted({tag for obs in observations for tag in obs.visible_tag_ids}) + package_ids = sorted( + key.removesuffix(".zone_id") + for obs in observations + for key in obs.facts + if key.startswith("PKG-") and key.endswith(".zone_id") + ) + return _json( + ok=True, + skill="scan_zone", + zone_id=zone_id, + visible_tag_ids=visible_tag_ids, + package_ids=package_ids, + source="simulation", + ) + + @skill + def camera_stream_status(self) -> str: + """Return whether DogOps has received a live camera frame.""" + return _json(skill="camera_stream_status", **self._camera_stream_status()) + + def _capture_latest_camera_image(self, context: dict[str, Any]) -> dict[str, Any] | None: + if self._latest_camera_image is None: + raise RuntimeError("no live Go2 camera frame is available for capture_image") + action = context.get("action") if isinstance(context.get("action"), dict) else {} + action_args = action.get("args") if isinstance(action, dict) and isinstance(action.get("args"), dict) else {} + max_age_s = _positive_float(action_args.get("max_camera_frame_age_s"), default=2.0) + frame_age_s = self._camera_frame_age_s() + if frame_age_s is None or frame_age_s > max_age_s: + raise RuntimeError( + f"latest Go2 camera frame is stale for capture_image: age={frame_age_s}s max={max_age_s}s" + ) + evidence_dir = Path(context["evidence_dir"]) + evidence_dir.mkdir(parents=True, exist_ok=True) + waypoint_id = str(context["waypoint_id"]) + action_id = str(context["action_id"]) + path = evidence_dir / f"{waypoint_id}-{action_id}.png" + _write_camera_frame_png(path, self._latest_camera_image) + return { + "path": str(path), + "source": "go2_camera_live", + "mime_type": "image/png", + "metadata": { + "camera_frame_age_s": frame_age_s, + "camera_frame_id": getattr(self._latest_camera_image, "frame_id", None), + "camera_shape": _image_shape(self._latest_camera_image), + "camera_encoding": getattr(self._latest_camera_image, "encoding", None), + }, + } + + @skill + def read_gauge(self, asset_id: str = "TEMP_1") -> str: + """Read or simulate a gauge value for a configured asset.""" + site = read_site_config(self.site_path) + asset = site.asset_by_id().get(asset_id) + if asset is None: + return _json(ok=False, skill="read_gauge", error="unknown_asset", asset_id=asset_id) + state = self._load_state_if_exists() + raw_reading, evidence_id = _latest_fact(state, f"{asset_id}.temperature_c") + threshold = _to_float(asset.expected_state.get("max_celsius")) + reading_celsius = _to_float(raw_reading) + source = "observation" if evidence_id is not None else "deterministic_expected_state" + if reading_celsius is None: + reading_celsius = _to_float(asset.expected_state.get("current_celsius")) + if reading_celsius is None and threshold is not None: + reading_celsius = round(threshold - 2.0, 1) + within_threshold = ( + None if reading_celsius is None or threshold is None else reading_celsius <= threshold + ) + status = asset.expected_status + if within_threshold is True: + status = "below_threshold" + elif within_threshold is False: + status = "above_threshold" + return _json( + ok=True, + skill="read_gauge", + asset_id=asset.id, + display_name=asset.display_name, + tag_id=asset.tag_id, + reading_celsius=reading_celsius, + max_celsius=threshold, + within_threshold=within_threshold, + state=status or "unknown", + evidence_observation_id=evidence_id, + source=source, + summary=( + f"{asset.id} reading {reading_celsius}C under {threshold}C." + if reading_celsius is not None and threshold is not None + else f"{asset.id} gauge read from expected state." + ), + ) + + @skill + def check_clearance(self, asset_id: str) -> str: + """Check whether an asset clearance is blocked.""" + site = read_site_config(self.site_path) + asset = site.asset_by_id().get(asset_id) + if asset is None: + return _json( + ok=False, + skill="check_clearance", + error="unknown_asset", + asset_id=asset_id, + ) + state = self._load_state_if_exists() + snapshot = _clearance_snapshot(asset, state) + return _json( + ok=True, + skill="check_clearance", + asset_id=asset.id, + display_name=asset.display_name, + tag_id=asset.tag_id, + expected_clear=asset.expected_clear, + **snapshot, + ) + + @skill + def detect_blocked_aisle(self, zone_id: str = "AISLE_1") -> str: + """Detect whether a configured aisle is blocked.""" + site = read_site_config(self.site_path) + asset = site.asset_by_id().get(zone_id) + if asset is None: + asset = next( + ( + candidate + for candidate in site.assets + if candidate.zone_id == zone_id + and candidate.asset_kind == "aisle_clearance" + ), + None, + ) + if asset is None: + return _json( + ok=False, + skill="detect_blocked_aisle", + error="unknown_aisle", + zone_id=zone_id, + ) + state = self._load_state_if_exists() + snapshot = _clearance_snapshot(asset, state) + open_blocked_incident = _has_open_incident(state, asset.id, IncidentType.blocked_aisle) + blocked = snapshot["clearance_clear"] is False or open_blocked_incident + return _json( + ok=True, + skill="detect_blocked_aisle", + zone_id=zone_id, + asset_id=asset.id, + display_name=asset.display_name, + blocked=blocked, + blocked_reason="blocked_aisle_incident" if open_blocked_incident else None, + **snapshot, + ) + + @skill + def scan_receiving_manifest(self, zone_id: str = "INBOUND_DOCK") -> str: + """Scan a receiving zone and compare observations with the manifest.""" + site = read_site_config(self.site_path) + if zone_id not in site.zone_by_id(): + return _json( + ok=False, + skill="scan_receiving_manifest", + error="unknown_zone", + zone_id=zone_id, + ) + manifest = read_manifest(self.manifest_path) + mission = read_mission(self.mission_path) + state = self._load_state_if_exists() + expected_package_ids = sorted( + item.package_id for item in manifest.items if item.expected_zone_id == zone_id + ) + observed_package_ids = _observed_packages_for_zone(state, mission, zone_id) + missing_package_ids = sorted(set(expected_package_ids) - set(observed_package_ids)) + unexpected_package_ids = sorted(set(observed_package_ids) - set(expected_package_ids)) + visible_tag_ids = _visible_tags_for_zone(state, mission, zone_id) + evidence_observation_ids = ( + [obs.id for obs in state.observations if obs.zone_id == zone_id] if state else [] + ) + manifest_exceptions = len(missing_package_ids) + len(unexpected_package_ids) + return _json( + ok=True, + skill="scan_receiving_manifest", + zone_id=zone_id, + expected_package_ids=expected_package_ids, + observed_package_ids=observed_package_ids, + missing_package_ids=missing_package_ids, + unexpected_package_ids=unexpected_package_ids, + manifest_exceptions=manifest_exceptions, + visible_tag_ids=visible_tag_ids, + evidence_observation_ids=evidence_observation_ids, + summary=( + f"{len(observed_package_ids)}/{len(expected_package_ids)} expected packages " + f"observed at {zone_id}." + ), + ) + + @skill + def inspect_asset(self, asset_id: str) -> str: + """Inspect a configured asset and return its current issue state.""" + site = read_site_config(self.site_path) + asset = site.asset_by_id().get(asset_id) + if asset is None: + return _json(ok=False, skill="inspect_asset", error="unknown_asset", asset_id=asset_id) + incidents = [] + if self._state_file().exists(): + state = DogOpsStore.load_existing(self.run_dir).state + assert state is not None + incidents = [ + incident.model_dump(mode="json") + for incident in state.incidents + if incident.entity_id == asset_id + ] + return _json( + ok=True, + skill="inspect_asset", + asset_id=asset.id, + display_name=asset.display_name, + expected_clear=asset.expected_clear, + incidents=incidents, + ) + + @skill + def reconcile_manifest(self) -> str: + """Reconcile the current run against the receiving manifest.""" + store = self._require_store("reconcile_manifest") + if isinstance(store, str): + return store + state = store.state + assert state is not None + report = build_report_data(state) + return _json( + ok=True, + skill="reconcile_manifest", + run_id=state.run.id, + packages_expected=report["packages_expected"], + packages_observed=report["packages_observed"], + manifest_exceptions=report["manifest_exceptions"], + open_issues=report["open_issues"], + ) + + @skill + def open_work_order(self, entity_id: str, issue_type: str) -> str: + """Open or return a work order for a DogOps incident.""" + store = self._require_store("open_work_order") + if isinstance(store, str): + return store + state = store.state + assert state is not None + for incident in state.incidents: + if incident.entity_id == entity_id and incident.type == issue_type: + work_order = _work_order_for_incident(state.work_orders, incident.id) + return _json( + ok=True, + skill="open_work_order", + incident_id=incident.id, + work_order_id=work_order.id if work_order else None, + state=incident.state, + summary="Existing work order returned.", + ) + + incident_type = IncidentType(issue_type) + rule = state.policy.rule_for_type(incident_type) + incident = Incident( + id=f"INC-{len(state.incidents) + 1:03d}", + run_id=state.run.id, + ts_open=time.time(), + severity=Severity(rule.severity) if rule else Severity.P2, + type=incident_type, + entity_id=entity_id, + related_package_id=entity_id if entity_id.startswith("PKG-") else None, + state=IncidentState.open, + title=f"{entity_id} {issue_type}", + recommended_action=rule.recommended_action if rule else "Review and remediate.", + ) + work_order = WorkOrder( + id=f"WO-{len(state.work_orders) + 1:03d}", + incident_id=incident.id, + requested_action=incident.recommended_action, + state=WorkOrderState.assigned, + ) + store.append_incident(incident) + store.append_work_order(work_order) + store.write_state(state.run.id) + store.write_report(state.run.id) + return _json( + ok=True, + skill="open_work_order", + incident_id=incident.id, + work_order_id=work_order.id, + state=incident.state, + ) + + @skill + def mark_ready_to_verify(self, work_order_id: str) -> str: + """Mark a work order ready for robot verification.""" + store = self._require_store("mark_ready_to_verify") + if isinstance(store, str): + return store + state = store.state + assert state is not None + work_order = _find_work_order(state.work_orders, work_order_id) + if work_order is None: + return _json( + ok=False, + skill="mark_ready_to_verify", + error="unknown_work_order", + work_order_id=work_order_id, + ) + if work_order.state != WorkOrderState.verified_closed: + work_order.state = WorkOrderState.ready_to_verify + incident = _find_incident(state.incidents, work_order.incident_id) + if incident is not None and incident.state != IncidentState.resolved: + incident.state = IncidentState.ready_to_verify + store.update_incident(incident) + store.update_work_order(work_order) + store.write_state(state.run.id) + store.write_report(state.run.id) + return _json( + ok=True, + skill="mark_ready_to_verify", + work_order_id=work_order.id, + state=work_order.state, + ) + + @skill + def verify_work_order(self, work_order_id: str) -> str: + """Verify a ready work order and close it if resolved.""" + store = self._require_store("verify_work_order") + if isinstance(store, str): + return store + state = store.state + assert state is not None + work_order = _find_work_order(state.work_orders, work_order_id) + if work_order is None: + return _json( + ok=False, + skill="verify_work_order", + error="unknown_work_order", + work_order_id=work_order_id, + ) + incident = _find_incident(state.incidents, work_order.incident_id) + if work_order.state != WorkOrderState.verified_closed: + work_order.state = WorkOrderState.verified_closed + if incident is not None: + incident.state = IncidentState.resolved + incident.ts_closed = time.time() + store.update_incident(incident) + store.update_work_order(work_order) + store.write_state(state.run.id) + store.write_report(state.run.id) + return _json( + ok=True, + skill="verify_work_order", + work_order_id=work_order.id, + state=work_order.state, + summary=f"{incident.entity_id if incident else work_order.id} verified closed.", + ) + + @skill + def what_changed(self, since_run_id: str | None = None) -> str: + """Summarize operational changes in the current DogOps run.""" + store = self._require_store("what_changed") + if isinstance(store, str): + return store + state = store.state + assert state is not None + if since_run_id is not None and since_run_id != state.run.id: + return _json( + ok=False, + skill="what_changed", + error="unknown_run", + since_run_id=since_run_id, + current_run_id=state.run.id, + ) + return _json(ok=True, skill="what_changed", run_id=state.run.id, changes=state.what_changed) + + @skill + def nav_eval_report(self, run_id: str | None = None) -> str: + """Return navigation evaluation metrics for a DogOps run.""" + store = self._require_store("nav_eval_report") + if isinstance(store, str): + return store + state = store.state + assert state is not None + if run_id is not None and run_id != state.run.id: + return _json( + ok=False, + skill="nav_eval_report", + error="unknown_run", + run_id=run_id, + current_run_id=state.run.id, + ) + return _json( + ok=True, + skill="nav_eval_report", + run_id=state.run.id, + nav_summary=state.nav_summary.model_dump(mode="json") if state.nav_summary else None, + ) + + @skill + def dock_align(self, dock_id: str = "DOCK_1") -> str: + """Report simulated AprilTag dock alignment readiness.""" + site = read_site_config(self.site_path) + if not any(entity.id == dock_id for entity in site.special_entities.values()): + return _json(ok=False, skill="dock_align", error="unknown_dock", dock_id=dock_id) + return _json( + ok=True, + skill="dock_align", + dock_id=dock_id, + simulated=True, + aligned=True, + guided=False, + ) + + @skill + def portal_entry(self, portal_id: str = "PORTAL_1") -> str: + """Report simulated readiness for gated portal entry.""" + site = read_site_config(self.site_path) + if not any(entity.id == portal_id for entity in site.special_entities.values()): + return _json(ok=False, skill="portal_entry", error="unknown_portal", portal_id=portal_id) + return _json( + ok=True, + skill="portal_entry", + portal_id=portal_id, + simulated=True, + door_open=True, + entered=True, + guided=False, + ) + + @skill + def stop_mission(self) -> str: + """Stop the current DogOps mission run if one is active.""" + if not self._state_file().exists(): + return _json(ok=True, skill="stop_mission", state="not_started") + store = DogOpsStore.load_existing(self.run_dir) + state = store.state + assert state is not None + if state.run.state not in {MissionState.done, MissionState.failed, MissionState.stopped}: + store.finish_run( + state.run.id, + MissionState.stopped, + "Mission stopped by DogOpsSkillContainer.", + ended_at=time.time(), + ) + return _json(ok=True, skill="stop_mission", run_id=state.run.id, state=state.run.state) + + def _state_file(self) -> Path: + return self.run_dir / "state.json" + + def _load_state_if_exists(self) -> DogOpsState | None: + if not self._state_file().exists(): + return None + store = DogOpsStore.load_existing(self.run_dir) + assert store.state is not None + return store.state + + def _scan_zone_with_latest_camera(self, zone_id: str) -> str | None: + if self._latest_camera_image is None: + return None + + site = read_site_config(self.site_path) + if zone_id not in site.zone_by_id(): + return _json(ok=False, skill="scan_zone", error="unknown_zone", zone_id=zone_id) + + try: + detections = DogOpsTagDetector(site).detect_dimos_image(self._latest_camera_image) + except RuntimeError as exc: + return _json( + ok=False, + skill="scan_zone", + error="camera_detector_unavailable", + message=str(exc), + zone_id=zone_id, + source="camera", + ) + + visible_tag_ids = sorted({detection.tag_id for detection in detections}) + package_ids = _package_ids_from_detections(detections) + evidence_observation_ids = self._persist_camera_observations( + detections, + zone_id=zone_id, + ) + return _json( + ok=True, + skill="scan_zone", + zone_id=zone_id, + visible_tag_ids=visible_tag_ids, + package_ids=package_ids, + source="camera", + detection_count=len(detections), + camera_frame_age_s=self._camera_frame_age_s(), + evidence_observation_ids=evidence_observation_ids, + ) + + def _persist_camera_observations( + self, + detections: list[DetectedTag], + *, + zone_id: str, + ) -> list[str]: + if not detections or not self._state_file().exists(): + return [] + + store = DogOpsStore.load_existing(self.run_dir) + state = store.state + assert state is not None + evidence_ids: list[str] = [] + observed_at = time.time() + for index, detection in enumerate(detections, start=1): + observation = _camera_observation( + detection, + zone_id=zone_id, + run_id=state.run.id, + observed_at=observed_at, + index=index, + ) + store.append_observation(observation) + _apply_observation_package_facts(state, observation) + evidence_ids.append(observation.id) + + store.write_state(state.run.id) + store.write_report(state.run.id) + return evidence_ids + + def _camera_stream_status(self) -> dict[str, object]: + if self._latest_camera_image is None: + return { + "ok": False, + "mode": "not_subscribed", + "fallback": "scan_zone uses deterministic fixtures until a camera frame arrives", + } + return { + "ok": True, + "mode": "latest_frame", + "frame_age_s": self._camera_frame_age_s(), + "frame_id": getattr(self._latest_camera_image, "frame_id", None), + "shape": _image_shape(self._latest_camera_image), + } + + def _camera_frame_age_s(self) -> float | None: + if self._latest_camera_received_at is None: + return None + return round(time.time() - self._latest_camera_received_at, 3) + + def _require_store(self, skill_name: str) -> DogOpsStore | str: + if not self._state_file().exists(): + return _json(ok=False, skill=skill_name, error="missing_run", run_dir=str(self.run_dir)) + return DogOpsStore.load_existing(self.run_dir) + + def _route_goal_publisher(self) -> CallableGoalPublisher | ClickedPointGoalPublisher | None: + if self._go_to_handler is not None: + return CallableGoalPublisher(self._go_to_handler) + publisher = getattr(self, "clicked_point", None) + if publisher is None or not hasattr(publisher, "publish"): + return None + return ClickedPointGoalPublisher(publisher, PointStamped) + + +def _find_incident(incidents: list[Incident], incident_id: str) -> Incident | None: + for incident in incidents: + if incident.id == incident_id: + return incident + return None + + +def _camera_observation( + detection: DetectedTag, + *, + zone_id: str, + run_id: str, + observed_at: float, + index: int, +) -> Observation: + facts = _camera_detection_facts(detection, zone_id) + return Observation( + id=f"CAM-{int(observed_at * 1000)}-{index:02d}-{detection.tag_id}", + ts=observed_at, + run_id=run_id, + entity_id=detection.entity_id, + tag_id=detection.tag_id, + zone_id=zone_id, + facts=facts, + confidence=detection.confidence, + source=detection.source, + ) + + +def _camera_detection_facts( + detection: DetectedTag, + zone_id: str, +) -> dict[str, bool | str | int | float]: + facts: dict[str, bool | str | int | float] = { + "entity_kind": detection.entity_kind or "unknown", + "detection_source": detection.source, + "frame_id": detection.frame_id or "", + "visible_tag_ids": str(detection.tag_id), + "center_px": _format_point(detection.center_px), + "area_px": round(detection.area_px, 3) if detection.area_px is not None else 0.0, + } + if detection.entity_id and detection.entity_id.startswith("PKG-"): + facts[f"{detection.entity_id}.zone_id"] = zone_id + return facts + + +def _apply_observation_package_facts(state: DogOpsState, obs: Observation) -> None: + for key, value in obs.facts.items(): + if not key.endswith(".zone_id"): + continue + package_id = key.removesuffix(".zone_id") + status = state.package_statuses.get(package_id) + if status is None or not isinstance(value, str): + continue + previous_zone = status.observed_zone_id + status.observed_zone_id = value + if value == status.expected_zone_id: + status.state = PackageState.found_ok + status.blocks_asset_id = None + else: + status.state = PackageState.wrong_zone + if package_id == "PKG-104" and previous_zone in {"RACK_ROW_A", "COOLING_1"}: + if value == "QA_HOLD": + state.what_changed.append( + "PKG-104 moved from COOLING_1/RACK_ROW_A to QA_HOLD; INC-001 resolved." + ) + + +def _package_ids_from_detections(detections: list[DetectedTag]) -> list[str]: + return sorted( + detection.entity_id + for detection in detections + if detection.entity_id is not None and detection.entity_id.startswith("PKG-") + ) + + +def _image_shape(image: object) -> list[int] | None: + shape = getattr(image, "shape", None) + data = getattr(image, "data", None) + if shape is None and data is not None: + shape = getattr(data, "shape", None) + if shape is None: + return None + return [int(item) for item in shape] + + +def _write_camera_frame_png(path: Path, image: object) -> None: + array = _camera_array_from_image(image) + shape = getattr(array, "shape", None) + if shape is None and hasattr(image, "shape"): + shape = getattr(image, "shape") + if shape is None or len(shape) < 2: + raise RuntimeError("latest camera frame does not expose a 2D image shape") + + height = int(shape[0]) + width = int(shape[1]) + channels = int(shape[2]) if len(shape) > 2 else 1 + encoding = str(getattr(image, "encoding", "") or "").lower() + rows = [] + for y in range(height): + row = bytearray() + for x in range(width): + row.extend(_rgb_pixel(array[y][x], channels=channels, encoding=encoding)) + rows.append(b"\x00" + bytes(row)) + raw = b"".join(rows) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + _png_chunk(b"tEXt", b"Description\x00DogOps live Go2 camera frame") + + _png_chunk(b"IDAT", zlib.compress(raw, level=6)) + + _png_chunk(b"IEND", b"") + ) + + +def _camera_array_from_image(image: object) -> Any: + if hasattr(image, "to_opencv"): + return image.to_opencv() # type: ignore[attr-defined] + data = getattr(image, "data", None) + if data is not None and hasattr(data, "shape"): + return data + return image + + +def _rgb_pixel(pixel: object, *, channels: int, encoding: str) -> bytes: + if channels <= 1: + value = _uint8(pixel) + return bytes((value, value, value)) + values = [_uint8(pixel[index]) for index in range(min(channels, 4))] # type: ignore[index] + if len(values) < 3: + value = values[0] if values else 0 + return bytes((value, value, value)) + if "rgb" in encoding and "bgr" not in encoding: + red, green, blue = values[:3] + else: + blue, green, red = values[:3] + return bytes((red, green, blue)) + + +def _uint8(value: object) -> int: + result = int(value) # type: ignore[arg-type] + return max(0, min(255, result)) + + +def _positive_float(value: object, *, default: float) -> float: + try: + result = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + if not math.isfinite(result) or result <= 0: + return default + return result + + +def _png_chunk(kind: bytes, data: bytes) -> bytes: + checksum = zlib.crc32(kind + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum) + + +def _format_point(point: tuple[float, float] | None) -> str: + if point is None: + return "" + return f"{point[0]:.1f},{point[1]:.1f}" + + +def _register_subscription(owner: object, subscription: object) -> None: + register = getattr(owner, "register_disposable", None) + if not callable(register): + return + if callable(subscription) and RxDisposable is not None: + register(RxDisposable(subscription)) + return + register(subscription) + + +def _find_work_order(work_orders: list[WorkOrder], work_order_id: str) -> WorkOrder | None: + for work_order in work_orders: + if work_order.id == work_order_id: + return work_order + return None + + +def _finite_point(x: float, y: float, z: float) -> tuple[float, float, float]: + try: + point = (float(x), float(y), float(z)) + except (TypeError, ValueError) as exc: + raise ValueError("go_to requires numeric x, y, and z") from exc + if not all(math.isfinite(value) for value in point): + raise ValueError("go_to target must be finite") + return point + + +def _work_order_for_incident(work_orders: list[WorkOrder], incident_id: str) -> WorkOrder | None: + for work_order in work_orders: + if work_order.incident_id == incident_id: + return work_order + return None + + +def _clearance_snapshot(asset: Asset, state: DogOpsState | None) -> dict[str, object]: + raw_clear, evidence_id = _latest_fact(state, f"{asset.id}.clearance_clear") + clearance_clear = _to_bool(raw_clear) + if clearance_clear is None: + clearance_clear = asset.expected_clear + blocking_package_ids = _blocking_package_ids(state, asset.id) + if not blocking_package_ids: + blocking_package_ids = sorted(asset.blocking_package_ids) + if blocking_package_ids: + clearance_clear = False + state_label = ( + "clear" if clearance_clear is True else "blocked" if clearance_clear is False else "unknown" + ) + return { + "clearance_clear": clearance_clear, + "state": state_label, + "blocking_package_ids": blocking_package_ids, + "evidence_observation_id": evidence_id, + } + + +def _latest_fact( + state: DogOpsState | None, key: str +) -> tuple[bool | str | int | float | None, str | None]: + if state is None: + return None, None + for obs in reversed(state.observations): + if key in obs.facts: + return obs.facts[key], obs.id + return None, None + + +def _blocking_package_ids(state: DogOpsState | None, asset_id: str) -> list[str]: + if state is None: + return [] + return sorted( + status.package_id + for status in state.package_statuses.values() + if status.blocks_asset_id == asset_id + ) + + +def _has_open_incident( + state: DogOpsState | None, entity_id: str, incident_type: IncidentType +) -> bool: + if state is None: + return False + return any( + incident.entity_id == entity_id + and incident.type == incident_type + and incident.state != IncidentState.resolved + for incident in state.incidents + ) + + +def _observed_packages_for_zone( + state: DogOpsState | None, mission: object, zone_id: str +) -> list[str]: + package_ids: set[str] = set() + if state is not None: + package_ids.update( + status.package_id + for status in state.package_statuses.values() + if status.observed_zone_id == zone_id + ) + for obs in state.observations: + if obs.zone_id == zone_id: + package_ids.update(_package_ids_from_observation(obs, zone_id)) + return sorted(package_ids) + + observations = mission.simulation_observations.values() + for obs in observations: + if obs.zone_id == zone_id: + package_ids.update(_package_ids_from_facts(obs.facts, zone_id)) + return sorted(package_ids) + + +def _visible_tags_for_zone( + state: DogOpsState | None, mission: object, zone_id: str +) -> list[int]: + tag_ids: set[int] = set() + if state is not None: + for obs in state.observations: + if obs.zone_id == zone_id: + tag_ids.update(_observation_tag_ids(obs)) + return sorted(tag_ids) + + observations = mission.simulation_observations.values() + for obs in observations: + if obs.zone_id == zone_id: + tag_ids.update(obs.visible_tag_ids) + return sorted(tag_ids) + + +def _package_ids_from_observation(obs: Observation, zone_id: str) -> set[str]: + return _package_ids_from_facts(obs.facts, zone_id) + + +def _package_ids_from_facts( + facts: dict[str, bool | str | int | float], zone_id: str +) -> set[str]: + return { + key.removesuffix(".zone_id") + for key, value in facts.items() + if key.startswith("PKG-") and key.endswith(".zone_id") and value == zone_id + } + + +def _observation_tag_ids(obs: Observation) -> set[int]: + tag_ids: set[int] = set() + if obs.tag_id is not None: + tag_ids.add(obs.tag_id) + raw_tag_ids = obs.facts.get("visible_tag_ids") + if isinstance(raw_tag_ids, str): + for item in raw_tag_ids.split(","): + item = item.strip() + if item: + tag_ids.add(int(item)) + return tag_ids + + +def _to_float(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def _to_bool(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "yes", "1", "clear"}: + return True + if normalized in {"false", "no", "0", "blocked"}: + return False + return None + + +def _json(**payload: object) -> str: + return json.dumps(payload, sort_keys=True) diff --git a/dimos/experimental/dogops/store.py b/dimos/experimental/dogops/store.py new file mode 100644 index 0000000000..376b64d701 --- /dev/null +++ b/dimos/experimental/dogops/store.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from dimos.experimental.dogops.models import ( + DogOpsState, + Incident, + Manifest, + MissionConfig, + MissionRun, + MissionState, + NavEvent, + Observation, + PackageStatus, + PolicyConfig, + SiteConfig, + WorkOrder, +) +from dimos.experimental.dogops.report import build_report_data, render_report_markdown + + +class DogOpsStore: + """JSON/JSONL run store for deterministic offline and demo runs.""" + + def __init__( + self, + root: str | Path, + *, + site: SiteConfig, + manifest: Manifest, + policy: PolicyConfig, + mission: MissionConfig, + ) -> None: + self.root = Path(root) + self.evidence_dir = self.root / "evidence" + self.site = site + self.manifest = manifest + self.policy = policy + self.mission = mission + self.state: DogOpsState | None = None + + def create_run(self, mission_id: str, started_at: float) -> MissionRun: + self.root.mkdir(parents=True, exist_ok=True) + self.evidence_dir.mkdir(parents=True, exist_ok=True) + run = MissionRun( + id=self.root.name, + mission_id=mission_id, + started_at=started_at, + state=MissionState.running, + summary="DogOps offline simulation running", + ) + package_statuses = { + item.package_id: PackageStatus( + package_id=item.package_id, + expected_zone_id=item.expected_zone_id, + ) + for item in self.manifest.items + } + self.state = DogOpsState( + run=run, + site=self.site, + manifest=self.manifest, + policy=self.policy, + mission=self.mission, + package_statuses=package_statuses, + ) + self._write_json(self.root / "run.json", run.model_dump(mode="json")) + self.write_state(run.id) + return run + + def finish_run(self, run_id: str, state: MissionState, summary: str, ended_at: float) -> MissionRun: + dogops_state = self._require_state(run_id) + dogops_state.run.state = state + dogops_state.run.summary = summary + dogops_state.run.ended_at = ended_at + dogops_state.run.current_step_id = None + self._write_json(self.root / "run.json", dogops_state.run.model_dump(mode="json")) + self.write_state(run_id) + return dogops_state.run + + def append_observation(self, obs: Observation) -> None: + state = self._require_state(obs.run_id) + state.observations.append(obs) + self._append_jsonl("observations.jsonl", obs.model_dump(mode="json")) + + def append_incident(self, incident: Incident) -> None: + state = self._require_state(incident.run_id) + state.incidents.append(incident) + self._append_jsonl("incidents.jsonl", incident.model_dump(mode="json")) + + def update_incident(self, incident: Incident) -> None: + state = self._require_state(incident.run_id) + state.incidents = [incident if item.id == incident.id else item for item in state.incidents] + self._rewrite_jsonl("incidents.jsonl", [item.model_dump(mode="json") for item in state.incidents]) + + def append_work_order(self, work_order: WorkOrder) -> None: + state = self._require_state_by_any_run() + state.work_orders.append(work_order) + self._append_jsonl("work_orders.jsonl", work_order.model_dump(mode="json")) + + def update_work_order(self, work_order: WorkOrder) -> None: + state = self._require_state_by_any_run() + state.work_orders = [ + work_order if item.id == work_order.id else item for item in state.work_orders + ] + self._rewrite_jsonl( + "work_orders.jsonl", [item.model_dump(mode="json") for item in state.work_orders] + ) + + def append_nav_event(self, nav_event: NavEvent) -> None: + state = self._require_state(nav_event.run_id) + state.nav_events.append(nav_event) + self._append_jsonl("nav_events.jsonl", nav_event.model_dump(mode="json")) + + def write_state(self, run_id: str) -> Path: + state = self._require_state(run_id) + path = self.root / "state.json" + self._write_json(path, state.model_dump(mode="json")) + return path + + def write_report(self, run_id: str) -> tuple[Path, Path]: + state = self._require_state(run_id) + report_json = self.root / "report.json" + report_md = self.root / "report.md" + self._write_json(report_json, build_report_data(state)) + report_md.write_text(render_report_markdown(state), encoding="utf-8") + return report_json, report_md + + def load_state(self, run_id: str | None = None) -> DogOpsState: + path = self.root / "state.json" + state = DogOpsState.model_validate_json(path.read_text(encoding="utf-8")) + if run_id is not None and state.run.id != run_id: + raise ValueError(f"loaded run {state.run.id!r}, expected {run_id!r}") + self.state = state + return state + + @classmethod + def load_existing(cls, root: str | Path) -> DogOpsStore: + path = Path(root) + state = DogOpsState.model_validate_json((path / "state.json").read_text(encoding="utf-8")) + store = cls( + path, + site=state.site, + manifest=state.manifest, + policy=state.policy, + mission=state.mission, + ) + store.state = state + return store + + def _require_state(self, run_id: str) -> DogOpsState: + if self.state is None: + raise RuntimeError("DogOps run has not been created") + if self.state.run.id != run_id: + raise ValueError(f"active run {self.state.run.id!r} does not match {run_id!r}") + return self.state + + def _require_state_by_any_run(self) -> DogOpsState: + if self.state is None: + raise RuntimeError("DogOps run has not been created") + return self.state + + def _append_jsonl(self, filename: str, payload: dict[str, object]) -> None: + with (self.root / filename).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + + def _rewrite_jsonl(self, filename: str, rows: list[dict[str, object]]) -> None: + with (self.root / filename).open("w", encoding="utf-8") as handle: + for row in rows: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + @staticmethod + def _write_json(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/dimos/experimental/dogops/tag_registry.py b/dimos/experimental/dogops/tag_registry.py new file mode 100644 index 0000000000..cbd208408d --- /dev/null +++ b/dimos/experimental/dogops/tag_registry.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from dimos.experimental.dogops.models import SiteConfig, SiteEntity + + +@dataclass(frozen=True) +class TagRegistration: + tag_id: int + entity_id: str + entity_kind: str + display_name: str + zone_id: str | None + + +class DogOpsTagRegistry: + def __init__(self, site: SiteConfig) -> None: + self.site = site + self._by_tag_id = { + tag_id: _registration_for_entity(tag_id, entity) + for tag_id, entity in site.entity_for_tag().items() + } + + def get(self, tag_id: int) -> TagRegistration | None: + return self._by_tag_id.get(tag_id) + + def require(self, tag_id: int) -> TagRegistration: + registration = self.get(tag_id) + if registration is None: + raise KeyError(f"unknown DogOps tag id: {tag_id}") + return registration + + def all(self) -> list[TagRegistration]: + return [self._by_tag_id[tag_id] for tag_id in sorted(self._by_tag_id)] + + +def _registration_for_entity(tag_id: int, entity: SiteEntity) -> TagRegistration: + return TagRegistration( + tag_id=tag_id, + entity_id=entity.id, + entity_kind=str(entity.kind), + display_name=entity.display_name, + zone_id=entity.zone_id, + ) diff --git a/dimos/experimental/dogops/test_blueprints.py b/dimos/experimental/dogops/test_blueprints.py new file mode 100644 index 0000000000..1d20bbae13 --- /dev/null +++ b/dimos/experimental/dogops/test_blueprints.py @@ -0,0 +1,21 @@ +from dimos.experimental.dogops.blueprints import ( + DogOpsBlueprintMetadata, + unitree_go2_dogops, +) +from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_dogops import ( + unitree_go2_dogops as exported_unitree_go2_dogops, +) + + +def test_unitree_go2_dogops_blueprint_fallback_is_no_key() -> None: + blueprint_text = repr(exported_unitree_go2_dogops) + assert "DogOpsSkillContainer" in blueprint_text + assert "DogOpsObservationModule" in blueprint_text + assert "DogOpsDashboardModule" in blueprint_text + assert "DogOpsNavEvalModule" in blueprint_text + assert "McpServer" in blueprint_text + assert "McpClient" not in blueprint_text + + if isinstance(unitree_go2_dogops, DogOpsBlueprintMetadata): + assert unitree_go2_dogops.name == "unitree-go2-dogops" + assert unitree_go2_dogops.requires_mcp_client is False diff --git a/dimos/experimental/dogops/test_cli_smoke.py b/dimos/experimental/dogops/test_cli_smoke.py new file mode 100644 index 0000000000..0cfb084aa0 --- /dev/null +++ b/dimos/experimental/dogops/test_cli_smoke.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_cli_validate_and_simulate(tmp_path) -> None: + validate = subprocess.run( + [sys.executable, "-m", "dimos.experimental.dogops.cli", "validate"], + check=True, + capture_output=True, + text=True, + ) + assert "validated site=dogops_demo_site" in validate.stdout + + run_dir = tmp_path / "latest" + simulate = subprocess.run( + [ + sys.executable, + "-m", + "dimos.experimental.dogops.cli", + "simulate", + "--out", + str(run_dir), + ], + check=True, + capture_output=True, + text=True, + ) + assert "state=done" in simulate.stdout + assert (run_dir / "report.md").is_file() + assert "PKG-104 wrong zone and blocking COOLING_1" in (run_dir / "report.md").read_text( + encoding="utf-8" + ) diff --git a/dimos/experimental/dogops/test_config_loader.py b/dimos/experimental/dogops/test_config_loader.py new file mode 100644 index 0000000000..7e8b12d925 --- /dev/null +++ b/dimos/experimental/dogops/test_config_loader.py @@ -0,0 +1,15 @@ +from dimos.experimental.dogops.config_loader import load_dogops_config + + +def test_load_dogops_demo_config() -> None: + config = load_dogops_config() + + assert config.site.site_id == "dogops_demo_site" + assert config.site.tag_family == "tag36h11" + assert config.site.marker_length_m == 0.14 + assert config.site.package_by_id()["PKG-104"].expected_zone_id == "QA_HOLD" + assert config.manifest.manifest_id == "inbound_manifest_demo" + assert len(config.manifest.items) == 4 + assert config.policy.rule_for_type("blocked_cooling") is not None + assert config.mission.mission_id == "receiving_sre_demo" + assert "inspect_cooling" in config.mission.simulation_observations diff --git a/dimos/experimental/dogops/test_dashboard.py b/dimos/experimental/dogops/test_dashboard.py new file mode 100644 index 0000000000..39242a65a6 --- /dev/null +++ b/dimos/experimental/dogops/test_dashboard.py @@ -0,0 +1,2686 @@ +from __future__ import annotations + +from html import unescape +import json +import math +from pathlib import Path +import re +import sys +import threading +import time +from typing import Any +import urllib.request + +import pytest + +from dimos.experimental.dogops import dashboard, dashboard_static, live_camera +from dimos.experimental.dogops.dashboard import DogOpsDashboardModule, make_dashboard_server +from dimos.experimental.dogops.dashboard_static import ( + build_map_data, + build_poi_data, + build_route_data, + write_dashboard_html, +) +from dimos.experimental.dogops.live_map import ( + DogOpsLiveMapAdapter, + LIVE_TOPIC_MAX_AGE_S, + _extend_dimos_package_path, + _grid_to_costmap, +) +from dimos.experimental.dogops.map_authoring import ( + EditableMapEntity, + EditableMapPoint, + EditableRoute, + EditableRouteWaypoint, + MapAuthoringState, + save_map_authoring, +) +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.route_executor import DogOpsRouteExecutor, save_route_execution +from dimos.experimental.dogops.route_actions import EditableRouteAction +from dimos.experimental.dogops.route_run_store import RouteRunStore + + +def _get_json(url: str) -> dict[str, object]: + with urllib.request.urlopen(url, timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +def _get_json_with_status( + url: str, + *, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, object]]: + request = urllib.request.Request(url, headers=headers or {}, method="GET") + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.status, json.loads(exc.read().decode("utf-8")) + + +def _post_json( + url: str, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, object]]: + request_headers = {"Content-Type": "application/json", **(headers or {})} + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=request_headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.status, json.loads(exc.read().decode("utf-8")) + + +def _put_json( + url: str, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, object]]: + request_headers = {"Content-Type": "application/json", **(headers or {})} + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=request_headers, + method="PUT", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.status, json.loads(exc.read().decode("utf-8")) + + +def _delete_json( + url: str, + *, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, object]]: + request = urllib.request.Request(url, headers=headers or {}, method="DELETE") + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.status, json.loads(exc.read().decode("utf-8")) + + +def _robot_headers(server) -> dict[str, str]: + return { + dashboard.ROBOT_CONTROL_TOKEN_HEADER: server.RequestHandlerClass.robot_control_token, + } + + +def test_dashboard_static_html_contains_closed_loop_result(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + + html_path = write_dashboard_html(run_dir) + content = html_path.read_text(encoding="utf-8") + + assert "DogOps SiteOps Agent" in content + assert "Mission Map" in content + assert "DimOS Camera" in content + assert "Waiting for /color_image" in content + assert 'data-camera-frame' in content + assert "/api/camera/frame.jpg" in content + assert '
    ' in content + assert content.index('
    ') < content.index('
    ') + assert content.index('

    Mission Map

    ') < content.index('

    DimOS Camera

    ') + assert content.index('

    DimOS Camera

    ') < content.index('

    Run Summary

    ') + assert 'data-map-surface' in content + assert "map-route" in content + assert "map-free-cell" in content + assert "map-live-cost-cell" in content + assert "map-dimos-path" in content + assert 'data-map-layer="heatmap"' in content + assert 'data-live-heatmap' in content + assert 'data-live-path' in content + assert 'data-live-target' in content + assert "refreshDimOSMap" in content + assert 'data-map-action="gather_heatmap"' in content + assert 'data-map-action="scan_zone"' in content + assert "/api/map/heatmap/gather" in content + assert "/api/robot/scan_zone" in content + assert 'data-map-edit-label-row' in content + assert 'data-map-edit-route-row' in content + assert 'data-map-edit-action="dry_run_route"' in content + assert 'data-map-edit-action="run_route"' in content + assert 'data-map-edit-action="stop_route"' in content + assert 'data-map-edit-action="heatmap_run"' in content + assert 'data-route-action-row' in content + assert 'data-route-action-kind="capture_image"' in content + assert 'data-route-action-kind="gemini_inspect_image"' in content + assert 'data-saved-images' in content + assert 'data-route-action-kind="scan_qr"' in content + assert 'data-route-action-kind="scan_tags"' in content + assert 'data-route-action-kind="wait"' in content + assert 'data-route-action-kind="inspect_asset"' in content + assert 'data-route-action-kind="verify_work_order"' in content + assert 'data-route-action-kind="operator_prompt"' in content + assert "Saved Routes" in content + assert 'data-route-table' in content + assert 'data-route-table-action="select"' in content + assert 'data-route-table-action="rename"' in content + assert 'data-route-table-action="duplicate"' in content + assert 'data-route-table-action="delete"' in content + assert "route-actions-subrow" in content + assert "routeActionRows(route)" in content + assert "handleRouteTableAction" in content + assert 'class="map-route-stop-marker"' in content + assert 'circle.setAttribute("r", "18")' in content + assert 'circle.setAttribute("r", "9")' in content + assert 'run.route_id === "GATHER_HEATMAP"' in content + assert "Costmap snapshot" in content + assert "new Set(Object.keys(routeActionLabels))" in content + assert "routeActionArgs(kind, waypoint)" in content + assert 'data-route-execution-status' in content + assert "/api/map/routes/follow" in content + assert "/api/map/routes/stop" in content + assert "/api/map/routes/status" in content + assert "runSelectedRoute(true)" in content + assert "runSelectedRoute(false)" in content + assert "addActionToSelectedRouteWaypoint" in content + assert "Dry run" in content + assert "Live" in content + assert "map-point" in content + assert "map-robot-core" in content + assert "free grid" in content + assert "tag return" in content + assert "no-go cost" in content + assert 'data-rerun-surface' in content + assert 'data-rerun-connect' in content + assert 'data-rerun-frame' in content + assert 'data-rerun-url=' in content + assert 'data-rerun-web-link' in content + assert "Rerun Web Visualization" in content + assert "connectRerunSurface" in content + assert 'data-map-command-status' in content + assert 'data-map-action="arm_go_to"' in content + assert 'data-map-edit-mode="home"' in content + assert 'data-map-edit-mode="no_go"' in content + assert 'data-map-edit-action="use_observation"' in content + assert 'data-map-edit-action="delete_selected"' in content + assert 'data-map-edit-action="route_select"' in content + assert 'data-map-edit-action="route_up"' in content + assert 'data-map-edit-action="route_down"' in content + assert 'data-map-edit-action="publish_no_go"' in content + assert 'data-map-edit-action="export"' in content + assert 'data-map-edit-label-row' in content + assert 'data-map-edit-route-row' in content + assert 'data-map-route-summary' in content + assert "Selected route: none. Next: Route1" in content + assert "AUTHORED_ROUTE" not in content + assert 'data-map-authoring-status' in content + assert "/api/map/authoring" in content + assert "/api/map/entities" in content + assert "/api/map/no_go_shapes" in content + assert "/api/map/no_go_shapes/publish" in content + assert "/api/map/tag_bindings" in content + assert "/from_observation" in content + assert "if (!current.selected_route_id) return null" in content + assert "selected_route_id: route.id" in content + assert 'data-go-to-marker' in content + assert "/api/robot/go_to" in content + assert "worldFromSvgEvent" in content + assert "map-zone no-go" not in content + assert "OBS-003" in content + assert "PKG-104" in content + assert "INC-001" in content + assert "Navigation Eval" in content + assert "Route / POI Evidence" in content + assert "Route Stops" in content + assert "POI Evidence" in content + assert "Robot Control" in content + assert "Checkpoint Sign-In" not in content + assert "Mission Timeline" not in content + assert "What Changed" not in content + assert "Current Run Timeline" in content + assert "Current Timeline" not in content + assert 'class="scan-strip"' not in content + assert "Tag Sign-In" in content + assert "OBS-005" in content + assert 'data-command="forward"' in content + assert 'data-command="hard_stop"' in content + assert 'data-command="yaw_left" data-key-hint="Q"' in content + assert 'data-command="yaw_right" data-key-hint="E"' in content + assert 'data-key-hint="W / Up"' in content + assert 'data-key-hint="Space / Esc"' in content + assert 'data-keyboard-map' in content + assert '["KeyW", "forward"]' in content + assert '["KeyQ", "yaw_left"]' in content + assert '["KeyE", "yaw_right"]' in content + assert '["ArrowDown", "backward"]' in content + assert '["Space", "hard_stop"]' in content + assert '["Escape", "hard_stop"]' in content + assert "shouldIgnoreKeyboardEvent" in content + assert 'data-posture="wake"' in content + assert 'data-posture="sleep"' in content + assert 'data-motion="nudge"' in content + assert 'data-motion="step"' in content + assert 'data-motion="walk"' in content + assert "X-DogOps-Control-Token" in content + + +def test_dashboard_static_embeds_full_authoring_state(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + site_id="dogops_demo_site", + entities=[ + EditableMapEntity( + id="CHECKPOINT_X", + kind="checkpoint", + label="Checkpoint X", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + routes=[ + EditableRoute( + id="ROUTE_X", + label="Route X", + waypoints=[ + EditableRouteWaypoint( + id="WP_X", + label="Waypoint X", + pose=EditableMapPoint(x=3.0, y=4.0), + ) + ], + ) + ], + ), + ) + + content = write_dashboard_html(run_dir).read_text(encoding="utf-8") + match = re.search(r'data-map-authoring="([^"]+)"', content) + assert match is not None + authoring = json.loads(unescape(match.group(1))) + + assert authoring["entities"][0]["id"] == "CHECKPOINT_X" + assert authoring["routes"][0]["waypoints"][0]["id"] == "WP_X" + assert not isinstance(authoring["entities"], int) + assert not isinstance(authoring["routes"], int) + + +def test_dashboard_map_layer_controls_match_svg_layers(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + + html_path = write_dashboard_html(run_dir) + content = html_path.read_text(encoding="utf-8") + + controls = set(re.findall(r'data-map-layer="([^"]+)"', content)) + layers = set(re.findall(r'data-layer="([^"]+)"', content)) + assert controls == {"semantic", "heatmap", "path", "robot", "qr"} + assert controls <= layers + assert 'querySelectorAll(`[data-layer="${layer}"]`)' in content + assert 'item.toggleAttribute("hidden", !pressed)' in content + assert "let dimosRobotPoseActive = false" in content + assert "if (dimosRobotPoseActive) return" in content + assert "let liveOverlayBounds = null" in content + assert "if (data.bounds) liveOverlayBounds = data.bounds" in content + assert "const projectWorldPoint = (x, y) => projectLivePose({x, y})" in content + assert "const projectLiveOverlayPoint = (x, y) => projectLiveOverlayPose({x, y})" in content + + +def test_dashboard_map_controls_are_grouped_near_legend(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + + html_path = write_dashboard_html(run_dir) + content = html_path.read_text(encoding="utf-8") + + label_row = re.search(r'
    (.*?)
    ', content) + route_row = re.search(r'
    (.*?)
    ', content) + assert label_row is not None + assert route_row is not None + assert 'data-map-edit-mode="zone"' in label_row.group(1) + assert 'data-map-edit-mode="asset"' in label_row.group(1) + assert 'data-map-edit-mode="no_go"' in label_row.group(1) + assert 'data-map-edit-mode="route"' not in label_row.group(1) + assert 'data-map-edit-mode="route"' in route_row.group(1) + assert 'data-map-edit-action="route_select"' in route_row.group(1) + assert 'data-map-edit-action="run_route"' in route_row.group(1) + assert 'data-map-edit-action="heatmap_run"' in route_row.group(1) + assert 'data-map-edit-action="route_add_action"' not in route_row.group(1) + assert 'data-map-edit-action="route_down"' in route_row.group(1) + assert 'data-map-route-summary' in route_row.group(1) + assert 'Last Run' in content + + svg_end = content.index("") + route_action_row = content.index('
    ', layer_controls) + assert svg_end < route_action_row < layer_controls < legend + assert 'Heatmap' in content + assert ".map-legend, .map-layer-controls" in content + assert "const nextRouteId" in content + assert "new id creates a route" in content + assert "label: routeId" in content + assert "routeTable.addEventListener(\"click\"" in content + assert 'data-route-run-select="' in content + assert "const overlayPath = Array.isArray(live.path) && live.path.length" in content + assert "? live.path" in content + assert ": data.route || []" in content + assert "mapEditControls.addEventListener(\"click\"" in content + assert ".map-route-table {" in content + assert ".route-run-history, .route-run-timeline {" in content + assert "background: #ffffff;" in content + assert "color: #111827;" in content + + +def test_dashboard_saved_routes_table_renders_selected_actions_and_escapes(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id='ROUTE_"A"&', + routes=[ + EditableRoute( + id='ROUTE_"A"&', + label='Route "quoted"', + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint ", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="ACT-1", + kind="scan_qr", + label='Scan "now"', + args={"expected": ['PAYLOAD<1>&"']}, + ) + ], + ) + ], + ), + EditableRoute( + id="ROUTE_B", + label="Route B", + waypoints=[ + EditableRouteWaypoint( + id="WP-2", + label="Waypoint 2", + pose=EditableMapPoint(x=2.0, y=3.0), + ) + ], + ), + ], + ), + ) + + html_path = write_dashboard_html(run_dir) + content = html_path.read_text(encoding="utf-8") + + assert "Saved Routes" in content + assert "Route <A> "quoted"" in content + assert 'data-route-id="ROUTE_"A"&"' in content + assert '' in content + assert "Waypoint <One>" in content + assert "Scan <QR> "now"" in content + assert "PAYLOAD<1>&\\"" in content + assert "11" in content + assert "Route B" in content + + +def test_dashboard_rerun_web_url_stays_loopback_only() -> None: + fallback = "http://127.0.0.1:9877" + + assert dashboard_static._trusted_rerun_web_url(None) == fallback + assert dashboard_static._trusted_rerun_web_url("http://127.0.0.1:9877") == fallback + assert ( + dashboard_static._trusted_rerun_web_url("http://localhost:9877/?dataset=dogops") + == "http://localhost:9877/?dataset=dogops" + ) + assert ( + dashboard_static._trusted_rerun_web_url("https://[::1]:9877") + == "https://[::1]:9877" + ) + assert dashboard_static._trusted_rerun_web_url("https://rerun.example.com") == fallback + assert dashboard_static._trusted_rerun_web_url("javascript:alert(1)") == fallback + + +def test_dashboard_module_writes_dashboard_and_reports_status(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + module = DogOpsDashboardModule(run_dir=run_dir, port=18765) + + html_path = module.write_dashboard() + status = module.status() + + assert html_path.endswith("dashboard.html") + assert status["exists"] is True + assert status["port"] == 18765 + + +def test_dashboard_api_serves_state_report_and_nav(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + with urllib.request.urlopen(f"{base_url}/", timeout=5) as response: + html = response.read().decode("utf-8") + state = _get_json(f"{base_url}/api/state") + report = _get_json(f"{base_url}/api/report") + nav = _get_json(f"{base_url}/api/nav") + camera_status = _get_json(f"{base_url}/api/camera/status") + map_data = _get_json(f"{base_url}/api/map") + authoring = _get_json(f"{base_url}/api/map/authoring") + route = _get_json(f"{base_url}/api/route") + poi = _get_json(f"{base_url}/api/poi") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert "DogOps SiteOps Agent" in html + assert state["run"]["state"] == "done" # type: ignore[index] + assert report["manifest_exceptions"] == 2 + assert report["checkpoints_verified"] == 4 + assert report["checkpoint_verifications"][2]["target_id"] == "COOLING_1" # type: ignore[index] + assert report["checkpoint_verifications"][2]["expected_tag_id"] == 41 # type: ignore[index] + assert nav["waypoints_reached"] == 4 + assert camera_status["source"] == "DimOS color_image" + assert camera_status["topic"] == "/color_image" + assert "received" in camera_status + assert [stop["target_id"] for stop in map_data["route"]] == [ + "HOME", + "INBOUND_DOCK", + "COOLING_1", + "QA_HOLD", + ] + assert [stop["target_id"] for stop in route["stops"]] == [ + "HOME", + "INBOUND_DOCK", + "COOLING_1", + "QA_HOLD", + ] + assert route["stops"][2]["tag_verified"] is True # type: ignore[index] + assert any(capture["id"] == "OBS-003" for capture in poi["captures"]) # type: ignore[index] + assert any(reading["asset_id"] == "TEMP_1" for reading in poi["readings"]) # type: ignore[index] + assert any(package["id"] == "PKG-104" for package in map_data["packages"]) + assert map_data["live"]["source"] == "DimOS live LCM topics" # type: ignore[index] + assert "costmap" in map_data["live"] # type: ignore[operator] + assert authoring["schema_version"] == 1 + assert authoring["site_id"] == "dogops_demo_site" + + +def test_dashboard_camera_status_and_frame_proxy(tmp_path, monkeypatch) -> None: + jpeg = ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + b"\xff\xdb\x00C\x00" + (b"\x08" * 64) + b"\xff\xd9" + ) + + class FakeLiveCameraAdapter: + def status(self) -> dict[str, object]: + return { + "ok": True, + "source": "DimOS color_image", + "topic": "/color_image", + "status": "receiving", + "error": "", + "received": True, + "age_s": 0.1, + "width": 640, + "height": 360, + "format": "RGB", + "frame_id": "camera_front", + } + + def frame_jpeg(self) -> bytes: + return jpeg + + monkeypatch.setattr(dashboard, "_LIVE_CAMERA_ADAPTER", FakeLiveCameraAdapter()) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + camera_status = _get_json(f"{base_url}/api/camera/status") + with urllib.request.urlopen(f"{base_url}/api/camera/frame.jpg", timeout=5) as response: + frame_content_type = response.headers["Content-Type"] + frame_payload = response.read() + forbidden_status, forbidden_result = _get_json_with_status( + f"{base_url}/api/camera/status", + headers={"Host": "192.0.2.10"}, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert camera_status["ok"] is True + assert camera_status["width"] == 640 + assert camera_status["height"] == 360 + assert frame_content_type == "image/jpeg" + assert frame_payload == jpeg + assert forbidden_status == 403 + assert forbidden_result["error"] == "local_read_only" + + +def test_live_camera_adapter_marks_stale_frames_pending() -> None: + class FakeFrame: + width = 640 + height = 360 + format = "RGB" + frame_id = "camera_front" + + def to_base64(self, *, quality: int = 75) -> str: + assert quality == 75 + return "ZmFrZQ==" + + adapter = live_camera.DogOpsLiveCameraAdapter() + adapter._started = True + adapter._latest = (time.time() - live_camera.LIVE_CAMERA_MAX_AGE_S - 0.1, FakeFrame()) + + status = adapter.status() + + assert status["ok"] is False + assert status["received"] is False + assert status["stale"] is True + assert status["status"] == "stale_frame" + assert adapter.frame_jpeg() is None + + +def test_dashboard_map_authoring_endpoints_persist_and_compose(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, entity_result = _post_json( + f"{base_url}/api/map/entities", + { + "id": "CHECKPOINT_X", + "kind": "checkpoint", + "label": "Checkpoint X", + "pose": {"x": 7.0, "y": 8.0, "source": "dashboard_edit"}, + "tag_id": 222, + }, + headers=_robot_headers(server), + ) + status_shape, shape_result = _post_json( + f"{base_url}/api/map/no_go_shapes", + { + "id": "NO_GO_EDIT", + "label": "Edited No-Go", + "shape": "rectangle", + "points": [ + {"x": 6.0, "y": 6.0, "source": "dashboard_edit"}, + {"x": 7.0, "y": 7.0, "source": "dashboard_edit"}, + ], + "enabled": True, + }, + headers=_robot_headers(server), + ) + status_route, route_result = _post_json( + f"{base_url}/api/map/routes", + { + "id": "ROUTE_EDIT", + "label": "Edited Route", + "waypoints": [ + { + "id": "WP1", + "label": "Waypoint 1", + "target_id": "CHECKPOINT_X", + "pose": {"x": 7.0, "y": 8.0, "source": "dashboard_edit"}, + } + ], + }, + headers=_robot_headers(server), + ) + map_data = _get_json(f"{base_url}/api/map") + with urllib.request.urlopen(f"{base_url}/dashboard.html", timeout=5) as response: + html = response.read().decode("utf-8") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert status_shape == 200 + assert status_route == 200 + assert entity_result["ok"] is True + assert shape_result["authoring"]["no_go_shapes"][0]["dimos_constraint_status"] == "not_supported" # type: ignore[index] + assert route_result["authoring"]["routes"][0]["id"] == "ROUTE_EDIT" # type: ignore[index] + assert (run_dir / "map_authoring.json").exists() + assert any(zone["id"] == "CHECKPOINT_X" for zone in map_data["zones"]) + assert map_data["route"][0]["target_id"] == "CHECKPOINT_X" # type: ignore[index] + assert map_data["no_go_shapes"][0]["id"] == "NO_GO_EDIT" # type: ignore[index] + match = re.search(r'data-map-authoring="([^"]+)"', html) + assert match is not None + embedded_authoring = json.loads(unescape(match.group(1))) + assert embedded_authoring["entities"][0]["id"] == "CHECKPOINT_X" + assert embedded_authoring["routes"][0]["id"] == "ROUTE_EDIT" + + +def test_dashboard_map_authoring_rejects_duplicate_tag_binding(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + payload = { + "tag_id": 222, + "entity_id": "CHECKPOINT_X", + "label": "Checkpoint X", + "binding_kind": "checkpoint", + } + try: + first_status, first = _post_json( + f"{base_url}/api/map/tag_bindings", + payload, + headers=_robot_headers(server), + ) + second_status, second = _post_json( + f"{base_url}/api/map/tag_bindings", + payload, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert first_status == 200 + assert first["ok"] is True + assert second_status == 400 + assert second["ok"] is False + assert second["error"] == "invalid_map_authoring" + + +def test_dashboard_map_authoring_write_requires_token(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/map/entities", + { + "id": "CHECKPOINT_FORBIDDEN", + "kind": "checkpoint", + "label": "Checkpoint Forbidden", + "pose": {"x": 1.0, "y": 2.0, "source": "dashboard_edit"}, + }, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["ok"] is False + assert result["error"] == "map_authoring_forbidden" + assert not (run_dir / "map_authoring.json").exists() + + +def test_dashboard_map_authoring_write_rejects_cross_origin(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/map/entities", + { + "id": "CHECKPOINT_BAD_ORIGIN", + "kind": "checkpoint", + "label": "Checkpoint Bad Origin", + "pose": {"x": 1.0, "y": 2.0, "source": "dashboard_edit"}, + }, + headers={**_robot_headers(server), "Origin": "https://example.com"}, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["ok"] is False + assert result["error"] == "map_authoring_bad_origin" + assert not (run_dir / "map_authoring.json").exists() + + +def test_dashboard_map_authoring_delete_and_export(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + _post_json( + f"{base_url}/api/map/entities", + { + "id": "CHECKPOINT_DELETE", + "kind": "checkpoint", + "label": "Checkpoint Delete", + "pose": {"x": 2.0, "y": 3.0, "source": "dashboard_edit"}, + }, + headers=_robot_headers(server), + ) + delete_status, delete_result = _delete_json( + f"{base_url}/api/map/entities/CHECKPOINT_DELETE", + headers=_robot_headers(server), + ) + export_status, export_result = _post_json( + f"{base_url}/api/map/export", + {}, + headers=_robot_headers(server), + ) + authoring = _get_json(f"{base_url}/api/map/authoring") + map_data = _get_json(f"{base_url}/api/map") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert delete_status == 200 + assert delete_result["ok"] is True + assert export_status == 200 + assert export_result["ok"] is True + assert not any( + entity["id"] == "CHECKPOINT_DELETE" + for entity in authoring["entities"] # type: ignore[index] + ) + assert not any(zone["id"] == "CHECKPOINT_DELETE" for zone in map_data["zones"]) + site_yaml = (run_dir / "exports" / "site_authoring.yaml") + assert site_yaml.exists() + assert "CHECKPOINT_DELETE" not in site_yaml.read_text(encoding="utf-8") + + +def test_dashboard_map_authoring_observation_placement_and_route_select(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_place, placed = _post_json( + f"{base_url}/api/map/entities/COOLING_1/from_observation", + {"observation_id": "OBS-003", "kind": "asset"}, + headers=_robot_headers(server), + ) + status_route, route_result = _post_json( + f"{base_url}/api/map/routes", + { + "id": "ROUTE_SELECT", + "label": "Route Select", + "waypoints": [ + { + "id": "WP_SELECT", + "label": "Waypoint Select", + "target_id": "COOLING_1", + "pose": {"x": 8.0, "y": 9.0, "source": "dashboard_edit"}, + } + ], + }, + headers=_robot_headers(server), + ) + status_select, selected = _post_json( + f"{base_url}/api/map/routes/ROUTE_SELECT/select", + {}, + headers=_robot_headers(server), + ) + map_data = _get_json(f"{base_url}/api/map") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_place == 200 + assert status_route == 200 + assert status_select == 200 + assert placed["authoring"]["entities"][0]["id"] == "COOLING_1" # type: ignore[index] + assert route_result["authoring"]["selected_route_id"] == "ROUTE_SELECT" # type: ignore[index] + assert selected["authoring"]["selected_route_id"] == "ROUTE_SELECT" # type: ignore[index] + assert map_data["authoring"]["selected_route_id"] == "ROUTE_SELECT" # type: ignore[index] + assert map_data["route"][0]["target_id"] == "COOLING_1" # type: ignore[index] + + +def test_dashboard_no_go_publish_keeps_unsupported_without_publisher(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + _post_json( + f"{base_url}/api/map/no_go_shapes", + { + "id": "NO_GO_PUBLISH", + "label": "Publish No-Go", + "shape": "rectangle", + "points": [ + {"x": 1.0, "y": 1.0, "source": "dashboard_edit"}, + {"x": 2.0, "y": 2.0, "source": "dashboard_edit"}, + ], + "enabled": True, + }, + headers=_robot_headers(server), + ) + status, result = _post_json( + f"{base_url}/api/map/no_go_shapes/publish", + {}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["authoring"]["no_go_shapes"][0]["dimos_constraint_status"] == "not_supported" # type: ignore[index] + + +def test_dashboard_no_go_publish_persists_published_status( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setenv("DOGOPS_NO_GO_PUBLISH_COMMAND", "true") + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + _post_json( + f"{base_url}/api/map/no_go_shapes", + { + "id": "NO_GO_PUBLISH", + "label": "Publish No-Go", + "shape": "rectangle", + "points": [ + {"x": 1.0, "y": 1.0, "source": "dashboard_edit"}, + {"x": 2.0, "y": 2.0, "source": "dashboard_edit"}, + ], + "enabled": True, + }, + headers=_robot_headers(server), + ) + status, result = _post_json( + f"{base_url}/api/map/no_go_shapes/publish", + {}, + headers=_robot_headers(server), + ) + authoring = _get_json(f"{base_url}/api/map/authoring") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["authoring"]["no_go_shapes"][0]["dimos_constraint_status"] == "published" # type: ignore[index] + assert authoring["no_go_shapes"][0]["dimos_constraint_status"] == "published" # type: ignore[index] + + +def test_dashboard_map_data_projects_site_route_and_observations(tmp_path) -> None: + run_dir = tmp_path / "latest" + state = run_offline_simulation(out=run_dir) + report = json.loads((run_dir / "report.json").read_text(encoding="utf-8")) + + map_data = build_map_data(state.model_dump(mode="json"), report) + + assert map_data["site_id"] == "dogops_demo_site" + assert {zone["id"] for zone in map_data["zones"]} >= {"HOME", "INBOUND_DOCK", "QA_HOLD"} + assert [stop["target_id"] for stop in map_data["route"]] == [ + "HOME", + "INBOUND_DOCK", + "COOLING_1", + "QA_HOLD", + ] + assert any(observation["id"] == "OBS-003" for observation in map_data["observations"]) + assert any(incident["id"] == "INC-001" for incident in map_data["incidents"]) + assert map_data["live"]["status"] == "not_requested" + + +def test_dashboard_map_data_includes_dimos_live_layers(tmp_path, monkeypatch) -> None: + class FakeLiveMapAdapter: + def snapshot(self) -> dict[str, object]: + return { + "ok": True, + "source": "DimOS live LCM topics", + "status": "receiving", + "error": "", + "topics": {"global_costmap": {"received": True}}, + "costmap": { + "source": "DimOS live costmap", + "columns": 1, + "rows": 1, + "cells": [{"x": 1.0, "y": 2.0, "width": 0.5, "height": 0.5, "cost": 0.9}], + }, + "path": [{"x": 1.0, "y": 2.0}, {"x": 2.0, "y": 3.0}], + "route": [{"target_id": "LIVE-PATH-001", "x": 1.0, "y": 2.0}], + "robot_pose": {"x": 1.2, "y": 2.1, "theta_deg": 45.0, "source": "odom"}, + "target": {"x": 2.0, "y": 3.0, "theta_deg": None, "source": "target"}, + } + + monkeypatch.setattr(dashboard, "_LIVE_MAP_ADAPTER", FakeLiveMapAdapter()) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + map_data = _get_json(f"{base_url}/api/map") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert map_data["live"]["ok"] is True # type: ignore[index] + assert map_data["live"]["costmap"]["source"] == "DimOS live costmap" # type: ignore[index] + assert map_data["live"]["path"][1]["x"] == 2.0 # type: ignore[index] + assert map_data["live"]["target"]["source"] == "target" # type: ignore[index] + assert map_data["layers"]["heatmap"] is True # type: ignore[index] + assert map_data["layers"]["path"] is True # type: ignore[index] + + +def test_dashboard_gather_heatmap_persists_snapshot_and_history(tmp_path, monkeypatch) -> None: + class FakeLiveMapAdapter: + def snapshot(self) -> dict[str, object]: + return { + "ok": True, + "source": "DimOS live LCM topics", + "status": "receiving", + "error": "", + "topics": {"navigation_costmap": {"received": True}}, + "costmap": { + "source": "DimOS live costmap", + "columns": 1, + "rows": 1, + "cells": [{"x": 1.0, "y": 2.0, "width": 0.5, "height": 0.5, "cost": 0.75}], + }, + "path": [], + "route": [], + "robot_pose": {"x": 1.2, "y": 2.1, "theta_deg": 45.0, "source": "odom"}, + "target": None, + } + + monkeypatch.setattr(dashboard, "_LIVE_MAP_ADAPTER", FakeLiveMapAdapter()) + run_dir = tmp_path / ".dogops" / "runs" / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/map/heatmap/gather", + {"area_id": "AISLE_1", "duration_s": 0}, + headers=_robot_headers(server), + ) + map_data = _get_json(f"{base_url}/api/map") + status_runs, route_runs = _get_json_with_status( + f"{base_url}/api/route-runs", + headers=_robot_headers(server), + ) + status_detail, detail = _get_json_with_status( + f"{base_url}/api/route-runs/{result['route_run_id']}", + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert status_runs == 200 + assert status_detail == 200 + assert result["ok"] is True + assert result["run_kind"] == "gather_heatmap" + assert result["heatmap"]["area_id"] == "AISLE_1" # type: ignore[index] + assert (run_dir / "heatmaps" / "latest_heatmap.json").is_file() + assert (run_dir / "heatmaps" / f"{result['route_run_id']}.json").is_file() + assert map_data["layers"]["heatmap"] is True # type: ignore[index] + assert map_data["gathered_heatmap"]["area_id"] == "AISLE_1" # type: ignore[index] + assert map_data["live"]["costmap"]["source"].startswith("Gathered heatmap") # type: ignore[index] + assert route_runs["route_runs"][0]["route_id"] == "GATHER_HEATMAP" # type: ignore[index] + assert route_runs["route_runs"][0]["transport"] == "dimos_costmap_snapshot" # type: ignore[index] + assert detail["map"]["gathered_heatmap"]["route_run_id"] == result["route_run_id"] # type: ignore[index] + assert detail["map"]["live"]["costmap"]["cells"][0]["cost"] == 0.75 # type: ignore[index] + + +def test_dashboard_gather_heatmap_without_costmap_records_failed_history(tmp_path, monkeypatch) -> None: + class FakeLiveMapAdapter: + def snapshot(self) -> dict[str, object]: + return { + "ok": False, + "source": "DimOS live LCM topics", + "status": "waiting", + "error": "no_costmap", + "topics": {"navigation_costmap": {"received": False}}, + "costmap": {"source": "DimOS live costmap", "cells": []}, + "path": [], + "route": [], + "robot_pose": None, + "target": None, + } + + monkeypatch.setattr(dashboard, "_LIVE_MAP_ADAPTER", FakeLiveMapAdapter()) + run_dir = tmp_path / ".dogops" / "runs" / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/map/heatmap/gather", + {"area_id": "AISLE_1", "duration_s": 0}, + headers=_robot_headers(server), + ) + map_data = _get_json(f"{base_url}/api/map") + status_runs, route_runs = _get_json_with_status( + f"{base_url}/api/route-runs", + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 409 + assert status_runs == 200 + assert result["ok"] is False + assert result["error"] == "heatmap_unavailable" + assert not (run_dir / "heatmaps" / "latest_heatmap.json").exists() + assert map_data["gathered_heatmap"] is None + assert route_runs["route_runs"][0]["route_id"] == "GATHER_HEATMAP" # type: ignore[index] + assert route_runs["route_runs"][0]["state"] == "failed" # type: ignore[index] + assert route_runs["route_runs"][0]["last_error"] == result["message"] # type: ignore[index] + + +def test_dashboard_route_action_authoring_persists_valid_actions(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + route_payload = EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="ACT-CAPTURE", + kind="capture_image", + label="Take picture", + required=True, + timeout_s=5.0, + args={}, + ), + EditableRouteAction( + id="ACT-GEMINI", + kind="gemini_inspect_image", + label="Gemini inspect", + required=True, + timeout_s=5.0, + args={"target": "WP-1"}, + ), + EditableRouteAction( + id="ACT-TAGS", + kind="scan_tags", + label="Scan AprilTags", + required=True, + timeout_s=5.0, + args={"expected": [101, 102]}, + ), + EditableRouteAction( + id="ACT-QR", + kind="scan_qr", + label="Scan QR", + required=True, + timeout_s=5.0, + args={"expected": ["QR-1"]}, + ), + EditableRouteAction( + id="ACT-WAIT", + kind="wait", + label="Wait", + required=True, + timeout_s=5.0, + args={"seconds": 2.0}, + ), + EditableRouteAction( + id="ACT-ASSET", + kind="inspect_asset", + label="Inspect asset", + required=True, + timeout_s=5.0, + args={"target": "ASSET_1"}, + ), + EditableRouteAction( + id="ACT-WO", + kind="verify_work_order", + label="Verify work order", + required=True, + timeout_s=5.0, + args={"target": "WO-001"}, + ), + EditableRouteAction( + id="ACT-PROMPT", + kind="operator_prompt", + label="Operator prompt", + required=True, + timeout_s=5.0, + args={"target": "WP-1"}, + ), + ], + ) + ], + ).model_dump(mode="json") + + try: + status, _ = _post_json( + f"{base_url}/api/map/routes", + route_payload, + headers=_robot_headers(server), + ) + authoring = _get_json(f"{base_url}/api/map/authoring") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + routes = authoring["routes"] # type: ignore[index] + actions = routes[0]["waypoints"][0]["actions"] # type: ignore[index] + assert [action["kind"] for action in actions] == [ + "capture_image", + "gemini_inspect_image", + "scan_tags", + "scan_qr", + "wait", + "inspect_asset", + "verify_work_order", + "operator_prompt", + ] + assert actions[0]["required"] is True + assert actions[1]["args"] == {"target": "WP-1"} + assert actions[2]["args"] == {"expected": [101, 102]} + assert actions[3]["args"] == {"expected": ["QR-1"]} + assert actions[4]["args"] == {"seconds": 2.0} + assert actions[5]["args"] == {"target": "ASSET_1"} + assert actions[6]["args"] == {"target": "WO-001"} + assert actions[7]["args"] == {"target": "WP-1"} + + +def test_dashboard_map_data_bounds_include_live_overlay(tmp_path) -> None: + run_dir = tmp_path / "latest" + state = run_offline_simulation(out=run_dir) + report = json.loads((run_dir / "report.json").read_text(encoding="utf-8")) + + map_data = build_map_data( + state.model_dump(mode="json"), + report, + live_overlay={ + "ok": True, + "source": "DimOS live LCM topics", + "status": "receiving", + "error": "", + "topics": {}, + "costmap": { + "cells": [{"x": 99.0, "y": 101.0, "width": 2.0, "height": 3.0, "cost": 1.0}] + }, + "path": [{"x": -20.0, "y": -10.0}], + "route": [], + "robot_pose": {"x": 120.0, "y": 130.0}, + "target": {"x": -30.0, "y": -40.0}, + }, + ) + + assert map_data["bounds"]["x_min"] <= -30.0 + assert map_data["bounds"]["y_min"] <= -40.0 + assert map_data["bounds"]["x_max"] >= 120.0 + assert map_data["bounds"]["y_max"] >= 130.0 + + +def test_live_map_adapter_does_not_assume_local_dimos_checkout(monkeypatch) -> None: + before = list(sys.path) + monkeypatch.delenv("DIMOS_ROOT", raising=False) + + _extend_dimos_package_path() + + assert sys.path == before + + +def test_live_map_adapter_snapshot_converts_recorded_dimos_messages() -> None: + class Pose: + def __init__(self, x: float, y: float, yaw: float = 0.0) -> None: + self.x = x + self.y = y + self.yaw = yaw + + class Path: + poses = [Pose(1.0, 2.0), Pose(3.0, 4.0)] + + class Costmap: + width = 2 + height = 2 + resolution = 0.5 + origin = Pose(-1.0, -2.0) + grid: list[list[int]] + + adapter = DogOpsLiveMapAdapter() + adapter._started = True + global_costmap = Costmap() + global_costmap.grid = [[0, 25], [50, 75]] + navigation_costmap = Costmap() + navigation_costmap.grid = [[100, 0], [0, 0]] + + adapter._record("global_costmap", global_costmap) + adapter._record("navigation_costmap", navigation_costmap) + adapter._record("odom", Pose(0.2, 0.3, math.pi / 2)) + adapter._record("path", Path()) + adapter._record("clicked_point", Pose(5.0, 6.0)) + + snapshot = adapter.snapshot() + + assert snapshot["ok"] is True + assert snapshot["status"] == "receiving" + assert snapshot["topics"]["global_costmap"]["received"] is True + assert snapshot["topics"]["navigation_costmap"]["received"] is True + assert snapshot["topics"]["clicked_point"]["received"] is True + assert snapshot["costmap"]["cells"][0]["cost"] == 1.0 + assert snapshot["path"] == [ + {"x": 1.0, "y": 2.0, "theta_deg": 0.0, "source": "path"}, + {"x": 3.0, "y": 4.0, "theta_deg": 0.0, "source": "path"}, + ] + assert snapshot["route"][1]["target_id"] == "LIVE-PATH-002" + assert snapshot["robot_pose"] == { + "x": 0.2, + "y": 0.3, + "theta_deg": 90.0, + "source": "odom", + } + assert snapshot["target"] == { + "x": 5.0, + "y": 6.0, + "theta_deg": 0.0, + "source": "target", + } + + +def test_live_map_adapter_snapshot_reports_waiting_without_topics() -> None: + adapter = DogOpsLiveMapAdapter() + adapter._started = True + + snapshot = adapter.snapshot() + + assert snapshot["ok"] is False + assert snapshot["status"] == "waiting_for_topics" + assert snapshot["costmap"] is None + assert snapshot["path"] == [] + assert snapshot["robot_pose"] is None + + +def test_live_map_adapter_snapshot_expires_stale_topics() -> None: + class Pose: + x = 1.0 + y = 2.0 + yaw = 0.0 + + adapter = DogOpsLiveMapAdapter() + adapter._started = True + adapter._latest["odom"] = (time.time() - LIVE_TOPIC_MAX_AGE_S - 1.0, Pose()) + + snapshot = adapter.snapshot() + + assert snapshot["ok"] is False + assert snapshot["topics"]["odom"]["received"] is False + assert snapshot["topics"]["odom"]["stale"] is True + assert snapshot["robot_pose"] is None + + +def test_live_costmap_downsampling_stays_within_source_bounds() -> None: + class Position: + x = 0.0 + y = 0.0 + + class Origin: + position = Position() + + class Costmap: + width = 50 + height = 50 + resolution = 1.0 + origin = Origin() + + Costmap.grid = [[0 for _ in range(Costmap.width)] for _ in range(Costmap.height)] + Costmap.grid[-1][-1] = 100 + + costmap = _grid_to_costmap(Costmap(), max_columns=48, max_rows=32) + + assert len(costmap["cells"]) == 48 * 32 + assert all(cell["width"] > 0 for cell in costmap["cells"]) # type: ignore[index] + assert all(cell["height"] > 0 for cell in costmap["cells"]) # type: ignore[index] + assert max(cell["x"] + cell["width"] for cell in costmap["cells"]) <= 50 # type: ignore[index] + assert max(cell["y"] + cell["height"] for cell in costmap["cells"]) <= 50 # type: ignore[index] + assert max(cell["cost"] for cell in costmap["cells"]) == 1.0 # type: ignore[index] + + +def test_dashboard_server_close_stops_live_adapter_and_robot_sessions(monkeypatch) -> None: + class Handler(dashboard.DogOpsDashboardHandler): + run_dir = Path(".") + robot_control_token = "test" + robot_ip = "192.168.12.1" + + class FakeLiveAdapter: + stopped = False + + def stop(self) -> None: + self.stopped = True + + class FakeRobotSession: + closed = False + + def close(self) -> None: + self.closed = True + + adapter = FakeLiveAdapter() + session = FakeRobotSession() + monkeypatch.setitem(dashboard._ROBOT_SESSIONS, "192.168.12.1", session) + server = dashboard.DogOpsDashboardServer(("127.0.0.1", 0), Handler, live_map_adapter=adapter) + + server.server_close() + + assert adapter.stopped is True + assert session.closed is True + assert dashboard._ROBOT_SESSIONS == {} + + +def test_dashboard_route_and_poi_data_project_evidence(tmp_path) -> None: + run_dir = tmp_path / "latest" + state = run_offline_simulation(out=run_dir) + report = json.loads((run_dir / "report.json").read_text(encoding="utf-8")) + + route = build_route_data(state.model_dump(mode="json"), report) + poi = build_poi_data(state.model_dump(mode="json"), report) + + assert route["route_coverage"] == 1.0 + assert route["stops"][2]["target_id"] == "COOLING_1" # type: ignore[index] + assert route["stops"][2]["expected_tag_id"] == 41 # type: ignore[index] + assert route["stops"][2]["tag_verified"] is True # type: ignore[index] + assert any(capture["id"] == "OBS-003" for capture in poi["captures"]) # type: ignore[index] + assert any( + reading["asset_id"] == "COOLING_1" and reading["clearance_clear"] is True + for reading in poi["readings"] # type: ignore[index] + ) + assert any( + reading["asset_id"] == "TEMP_1" and reading["within_threshold"] is True + for reading in poi["readings"] # type: ignore[index] + ) + + +def test_dashboard_robot_jog_sends_low_speed_bounded_pulse(tmp_path, monkeypatch) -> None: + calls: list[tuple[float, float, float, float, str]] = [] + + def fake_publish( + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + robot_ip: str, + ) -> None: + calls.append((linear_x, linear_y, angular_z, duration_s, robot_ip)) + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fake_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "forward", "duration_s": 99}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["linear_x"] == 0.15 + assert result["duration_s"] == dashboard.MAX_JOG_DURATION_S + assert result["profile"] == "nudge" + assert calls == [(0.15, 0.0, 0.0, dashboard.MAX_JOG_DURATION_S, "192.168.12.1")] + + +def test_dashboard_robot_jog_applies_motion_profile(tmp_path, monkeypatch) -> None: + calls: list[tuple[float, float, float, float, str]] = [] + + def fake_publish( + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + robot_ip: str, + ) -> None: + calls.append((linear_x, linear_y, angular_z, duration_s, robot_ip)) + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fake_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "forward", "profile": "walk"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["profile"] == "walk" + assert result["linear_x"] == pytest.approx(0.6) + assert result["duration_s"] == 2.0 + assert calls == [(0.6, 0.0, 0.0, 2.0, "192.168.12.1")] + + +def test_dashboard_robot_jog_ignores_payload_robot_ip(tmp_path, monkeypatch) -> None: + calls: list[str] = [] + + def fake_publish( + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + robot_ip: str, + ) -> None: + calls.append(robot_ip) + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fake_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "forward", "robot_ip": "10.0.0.99"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert "robot_ip" not in result + assert calls == ["192.168.12.1"] + + +def test_dashboard_robot_control_requires_token(tmp_path, monkeypatch) -> None: + def fail_publish(*_: object) -> None: + raise AssertionError("unauthorized robot control must not publish") + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fail_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json(f"{base_url}/api/robot/jog", {"command": "forward"}) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["ok"] is False + assert result["error"] == "robot_control_forbidden" + + +def test_dashboard_robot_control_rejects_non_loopback_host(tmp_path, monkeypatch) -> None: + def fail_publish(*_: object) -> None: + raise AssertionError("non-local robot control must not publish") + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fail_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "forward"}, + headers={ + **_robot_headers(server), + "Host": "192.168.1.10:8765", + }, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["ok"] is False + assert result["error"] == "robot_control_local_only" + + +def test_dashboard_robot_control_rejects_cross_origin(tmp_path, monkeypatch) -> None: + def fail_publish(*_: object) -> None: + raise AssertionError("cross-origin robot control must not publish") + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fail_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "forward"}, + headers={ + **_robot_headers(server), + "Origin": "https://example.com", + }, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["ok"] is False + assert result["error"] == "robot_control_bad_origin" + + +def test_motion_profile_falls_back_and_caps_speed() -> None: + linear_x, linear_y, angular_z, duration_s, profile = dashboard._resolve_motion_request( + "forward", + {"profile": "too_fast", "duration_s": 99}, + ) + + assert profile == "nudge" + assert linear_x == 0.15 + assert linear_y == 0.0 + assert angular_z == 0.0 + assert duration_s == dashboard.MAX_JOG_DURATION_S + + +def test_dashboard_robot_hard_stop_uses_hard_stop_publisher(tmp_path, monkeypatch) -> None: + jog_calls: list[tuple[float, float, float, float, str]] = [] + hard_stop_calls: list[str] = [] + + def fake_publish( + linear_x: float, + linear_y: float, + angular_z: float, + duration_s: float, + robot_ip: str, + ) -> None: + jog_calls.append((linear_x, linear_y, angular_z, duration_s, robot_ip)) + + def fake_hard_stop(robot_ip: str) -> None: + hard_stop_calls.append(robot_ip) + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fake_publish) + monkeypatch.setattr(dashboard, "_publish_robot_hard_stop", fake_hard_stop) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "hard_stop"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["duration_s"] == 0.0 + assert jog_calls == [] + assert hard_stop_calls == ["192.168.12.1"] + + +def test_dashboard_robot_jog_rejects_unknown_command(tmp_path, monkeypatch) -> None: + def fail_publish(*_: object) -> None: + raise AssertionError("unknown commands must not publish") + + monkeypatch.setattr(dashboard, "_publish_robot_jog", fail_publish) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/jog", + {"command": "sprint"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 400 + assert result["ok"] is False + assert result["error"] == "unknown_robot_command" + + +def test_dashboard_robot_posture_wake_calls_posture_runner(tmp_path, monkeypatch) -> None: + calls: list[tuple[str, str]] = [] + + def fake_posture(command: str, robot_ip: str) -> bool: + calls.append((command, robot_ip)) + return True + + monkeypatch.setattr(dashboard, "_run_robot_posture", fake_posture) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/posture", + {"command": "wake", "robot_ip": "192.168.12.1"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["command"] == "wake" + assert "robot_ip" not in result + assert calls == [("wake", "192.168.12.1")] + + +def test_dashboard_robot_posture_rejects_unknown_command(tmp_path, monkeypatch) -> None: + def fail_posture(*_: object) -> bool: + raise AssertionError("unknown posture commands must not run") + + monkeypatch.setattr(dashboard, "_run_robot_posture", fail_posture) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/posture", + {"command": "dance"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 400 + assert result["ok"] is False + assert result["error"] == "unknown_posture_command" + + +def test_dashboard_robot_go_to_calls_dimos_bridge(tmp_path, monkeypatch) -> None: + calls: list[tuple[float, float]] = [] + + def fake_go_to(x: float, y: float) -> dict[str, object]: + calls.append((x, y)) + return {"transport": "dimos_mcp", "skill": "go_to"} + + monkeypatch.setattr(dashboard, "_run_robot_go_to", fake_go_to) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/go_to", + {"command": "go_to", "x": 1.25, "y": -0.5, "source": "map_click"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["command"] == "go_to" + assert result["source"] == "map_click" + assert result["transport"] == "dimos_mcp" + assert result["skill"] == "go_to" + assert calls == [(1.25, -0.5)] + + +def test_dashboard_robot_scan_zone_calls_dimos_bridge(tmp_path, monkeypatch) -> None: + calls: list[str] = [] + + def fake_scan(zone_id: str) -> dict[str, object]: + calls.append(zone_id) + return { + "transport": "dimos_mcp", + "skill": "scan_zone", + "mcp_result": { + "ok": True, + "source": "camera", + "visible_tag_ids": [104], + "package_ids": ["PKG-104"], + }, + } + + monkeypatch.setattr(dashboard, "_run_robot_scan_zone", fake_scan) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/scan_zone", + {"command": "scan_zone", "zone_id": "QA_HOLD"}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert result["ok"] is True + assert result["command"] == "scan_zone" + assert result["zone_id"] == "QA_HOLD" + assert result["transport"] == "dimos_mcp" + assert result["skill"] == "scan_zone" + assert result["mcp_result"]["source"] == "camera" # type: ignore[index] + assert calls == ["QA_HOLD"] + + +def test_dashboard_robot_go_to_rejects_bad_target(tmp_path, monkeypatch) -> None: + def fail_go_to(*_: object) -> dict[str, object]: + raise AssertionError("bad go_to targets must not run") + + monkeypatch.setattr(dashboard, "_run_robot_go_to", fail_go_to) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/robot/go_to", + {"command": "go_to", "x": "nan", "y": 0.0}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 400 + assert result["ok"] is False + assert result["error"] == "invalid_go_to_target" + + +def test_dashboard_route_follow_stop_and_status_endpoints(tmp_path, monkeypatch) -> None: + calls: list[tuple[str | None, bool, bool]] = [] + timeouts: list[float] = [] + monkeypatch.setattr(dashboard._LIVE_MAP_ADAPTER, "snapshot", lambda: {}) + + def fake_follow( + route_id: str | None, + dry_run: bool, + *, + use_planner: bool = False, + **_: object, + ) -> dict[str, object]: + calls.append((route_id, dry_run, use_planner)) + return { + "transport": "dimos_mcp", + "skill": "follow_route", + "mcp_result": { + "ok": True, + "route_id": route_id, + "state": "completed", + "route_execution": {"route_id": route_id, "state": "completed"}, + }, + } + + def fake_stop() -> dict[str, object]: + return { + "transport": "dimos_mcp", + "skill": "stop_route", + "mcp_result": {"ok": True, "state": "stopped", "route_execution": {"state": "stopped"}}, + } + + monkeypatch.setattr(dashboard, "_run_robot_follow_route", fake_follow) + monkeypatch.setattr(dashboard, "_run_robot_stop_route", fake_stop) + monkeypatch.setattr(dashboard, "_run_route_hard_stop", lambda robot_ip: {"robot_ip": robot_ip}) + original_run_robot_call = dashboard._run_robot_call + + def tracking_run_robot_call(fn: Any, *, timeout_s: float = dashboard.ROBOT_CALL_TIMEOUT_S) -> object: + timeouts.append(timeout_s) + return original_run_robot_call(fn, timeout_s=timeout_s) + + monkeypatch.setattr(dashboard, "_run_robot_call", tracking_run_robot_call) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_follow, follow_result = _post_json( + f"{base_url}/api/map/routes/follow", + {"route_id": "ROUTE_A", "dry_run": True}, + headers=_robot_headers(server), + ) + status_status, status_result = _get_json_with_status( + f"{base_url}/api/map/routes/status", + headers=_robot_headers(server), + ) + status_stop, stop_result = _post_json( + f"{base_url}/api/map/routes/stop", + {}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_follow == 200 + assert follow_result["ok"] is True + assert follow_result["command"] == "follow_route" + assert follow_result["route_execution"]["state"] == "completed" # type: ignore[index] + assert follow_result["authoring"]["selected_route_id"] == "ROUTE_A" # type: ignore[index] + assert calls == [("ROUTE_A", True, False)] + assert timeouts[0] == dashboard.DIMOS_ROUTE_CALL_TIMEOUT_S + assert status_status == 200 + assert status_result["ok"] is True + assert status_result["route_execution"]["state"] == "idle" # type: ignore[index] + assert status_stop == 200 + assert stop_result["ok"] is True + assert stop_result["command"] == "stop_route" + assert stop_result["route_execution"]["state"] == "stopped" # type: ignore[index] + assert stop_result["hard_stop"]["ok"] is True # type: ignore[index] + assert stop_result["hard_stop"]["robot_ip"] == "192.168.12.1" # type: ignore[index] + + +def test_dashboard_route_follow_defaults_to_direct_for_live_runs(tmp_path, monkeypatch) -> None: + calls: list[tuple[str, str | None, bool]] = [] + + def fake_direct( + route_id: str | None, + *, + robot_ip: str, + run_dir: Path | None, + ) -> dict[str, object]: + assert robot_ip == "192.168.12.1" + assert run_dir == tmp_path + calls.append(("direct", route_id, False)) + return {"ok": True, "transport": "direct_webrtc"} + + def fake_planner(route_id: str | None, dry_run: bool) -> dict[str, object]: + calls.append(("planner", route_id, dry_run)) + return {"ok": True, "transport": "dimos_mcp"} + + monkeypatch.setattr(dashboard, "_run_robot_follow_route_direct", fake_direct) + monkeypatch.setattr(dashboard, "_run_robot_follow_route_with_planner", fake_planner) + + assert dashboard._run_robot_follow_route( + "ROUTE_A", + False, + robot_ip="192.168.12.1", + run_dir=tmp_path, + )["transport"] == "direct_webrtc" + assert dashboard._run_robot_follow_route( + "ROUTE_A", + False, + use_planner=True, + robot_ip="192.168.12.1", + run_dir=tmp_path, + )["transport"] == "dimos_mcp" + assert dashboard._run_robot_follow_route( + "ROUTE_A", + True, + robot_ip="192.168.12.1", + run_dir=tmp_path, + )["transport"] == "dimos_mcp" + assert calls == [ + ("direct", "ROUTE_A", False), + ("planner", "ROUTE_A", False), + ("planner", "ROUTE_A", True), + ] + + +def test_dashboard_route_follow_reports_mcp_unavailable(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(dashboard._LIVE_MAP_ADAPTER, "snapshot", lambda: {}) + + def fail_follow(route_id: str | None, dry_run: bool, **_: object) -> dict[str, object]: + raise RuntimeError("dimos mcp call follow_route failed: no running MCP server") + + monkeypatch.setattr(dashboard, "_run_robot_follow_route", fail_follow) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _post_json( + f"{base_url}/api/map/routes/follow", + {"route_id": "ROUTE_A", "dry_run": True}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 503 + assert result["ok"] is False + assert result["error"] == "dimos_mcp_unavailable" + assert "no running MCP server" in result["message"] + + +def test_dashboard_stop_syncs_route_history_when_mcp_stop_fails(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(dashboard, "_run_route_hard_stop", lambda robot_ip: {"robot_ip": robot_ip}) + monkeypatch.setattr( + dashboard, + "_run_robot_stop_route", + lambda: (_ for _ in ()).throw(ModuleNotFoundError("no dimos")), + ) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + route_state = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + route_state.state = "running" + route_state.stop_requested = False + save_route_execution(run_dir, route_state) + RouteRunStore(run_dir).sync_execution_state(route_state) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_stop, stop_result = _post_json( + f"{base_url}/api/map/routes/stop", + {}, + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_stop == 503 + assert stop_result["error"] == "dimos_mcp_unavailable" + route_run = RouteRunStore(run_dir).route_run_detail(route_state.route_run_id or "") + assert route_run["state"] == "stopped" + events = RouteRunStore(run_dir).route_run_events(route_state.route_run_id or "") + assert events[-1]["state"] == "stopped" + + +def test_dashboard_route_status_requires_token(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status, result = _get_json_with_status(f"{base_url}/api/map/routes/status") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 403 + assert result["error"] == "map_authoring_forbidden" + + +def test_dashboard_route_run_history_endpoints(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + route_state = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_list, route_runs = _get_json_with_status( + f"{base_url}/api/route-runs", + headers=_robot_headers(server), + ) + status_current, current = _get_json_with_status( + f"{base_url}/api/route-runs/current", + headers=_robot_headers(server), + ) + status_events, events = _get_json_with_status( + f"{base_url}/api/route-runs/{route_state.route_run_id}/events", + headers=_robot_headers(server), + ) + status_detail, detail = _get_json_with_status( + f"{base_url}/api/route-runs/{route_state.route_run_id}", + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_list == 200 + assert route_runs["route_runs"][0]["route_run_id"] == route_state.route_run_id # type: ignore[index] + assert status_current == 200 + assert current["route_run"]["route_run_id"] == route_state.route_run_id # type: ignore[index] + assert current["events"][0]["state"] == "queued" # type: ignore[index] + timeline_kinds = {row["kind"] for row in current["timeline"]} # type: ignore[index] + assert {"waypoint", "observation", "incident", "work_order", "verification"} <= timeline_kinds + assert status_events == 200 + assert events["events"][0]["route_run_id"] == route_state.route_run_id # type: ignore[index] + assert status_detail == 200 + assert detail["map"]["route"][0]["target_id"] == "WP-1" # type: ignore[index] + assert detail["map"]["route"][0]["x"] == 1.0 # type: ignore[index] + + +def test_dashboard_route_run_images_api_serves_saved_image_files(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_IMAGE", + routes=[ + EditableRoute( + id="ROUTE_IMAGE", + label="Route Image", + waypoints=[ + EditableRouteWaypoint( + id="WP-IMAGE", + label="Waypoint Image", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"target": "COOLING_1"}, + ) + ], + ) + ], + ) + ], + ), + ) + route_state = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_images, image_list = _get_json_with_status( + f"{base_url}/api/route-runs/images", + headers=_robot_headers(server), + ) + image = image_list["images"][0] # type: ignore[index] + request = urllib.request.Request( + f"{base_url}{image['url']}", # type: ignore[index] + headers=_robot_headers(server), + method="GET", + ) + with urllib.request.urlopen(request, timeout=5) as response: + image_status = response.status + image_type = response.headers["Content-Type"] + image_bytes = response.read() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert route_state.route_run_id + assert status_images == 200 + assert image["route_run_id"] == route_state.route_run_id # type: ignore[index] + assert image["dogops_run_id"] == "latest" # type: ignore[index] + assert image["metadata"]["waypoint_id"] == "WP-IMAGE" # type: ignore[index] + assert image_status == 200 + assert image_type == "image/png" + assert image_bytes.startswith(b"\x89PNG\r\n\x1a\n") + + +def test_dashboard_route_run_detail_uses_historical_run_dir(tmp_path) -> None: + first_run_dir = tmp_path / ".dogops" / "runs" / "first" + second_run_dir = tmp_path / ".dogops" / "runs" / "second" + for run_dir, route_id in ((first_run_dir, "ROUTE_FIRST"), (second_run_dir, "ROUTE_SECOND")): + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id=route_id, + routes=[ + EditableRoute( + id=route_id, + label=route_id, + waypoints=[ + EditableRouteWaypoint( + id=f"{route_id}-WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + second_report_path = second_run_dir / "report.json" + second_report = json.loads(second_report_path.read_text(encoding="utf-8")) + second_report["incidents"][0]["title"] = "second-run-only incident" + second_report_path.write_text(json.dumps(second_report), encoding="utf-8") + + DogOpsRouteExecutor(first_run_dir).follow_route(dry_run=True) + second_route_state = DogOpsRouteExecutor(second_run_dir).follow_route(dry_run=True) + server = make_dashboard_server(first_run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_detail, detail = _get_json_with_status( + f"{base_url}/api/route-runs/{second_route_state.route_run_id}", + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_detail == 200 + assert detail["route_run"]["dogops_run_id"] == "second" # type: ignore[index] + timeline_notes = {row["note"] for row in detail["timeline"]} # type: ignore[index] + assert "second-run-only incident" in timeline_notes + + +def test_dashboard_route_run_detail_survives_missing_run_files(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="Waypoint 1", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + route_state = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + (run_dir / "state.json").unlink() + (run_dir / "report.json").unlink() + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + status_detail, detail = _get_json_with_status( + f"{base_url}/api/route-runs/{route_state.route_run_id}", + headers=_robot_headers(server), + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status_detail == 200 + assert detail["route_run"]["route_run_id"] == route_state.route_run_id # type: ignore[index] + assert [row["kind"] for row in detail["timeline"]] == ["waypoint"] # type: ignore[index] + + +def test_dimos_mcp_call_command_prefers_configured_prefix(monkeypatch) -> None: + monkeypatch.setenv("DOGOPS_DIMOS_MCP_CALL", "python -m dimos mcp call") + + command = dashboard._dimos_mcp_call_command("go_to", {"x": 1.0, "y": 2.0}) + + assert command == [ + "python", + "-m", + "dimos", + "mcp", + "call", + "go_to", + "--json-args", + '{"x":1.0,"y":2.0}', + ] + + +def test_dimos_mcp_call_skill_treats_tool_error_as_failure(monkeypatch) -> None: + class _Result: + returncode = 0 + stdout = '{"ok":false,"error":"navigation_stream_unavailable"}' + stderr = "" + + monkeypatch.setattr(dashboard.subprocess, "run", lambda *_, **__: _Result()) + monkeypatch.setattr(dashboard, "_dimos_mcp_call_command", lambda *_: ["dimos", "mcp"]) + + with pytest.raises(RuntimeError, match="navigation_stream_unavailable"): + dashboard._call_dimos_mcp_skill("go_to", {"x": 1.0, "y": 2.0}) + + +@pytest.mark.parametrize( + ("command", "linear_x", "linear_y", "angular_z"), + [ + ("forward", 0.15, 0.0, 0.0), + ("backward", -0.15, 0.0, 0.0), + ("left", 0.0, 0.15, 0.0), + ("right", 0.0, -0.15, 0.0), + ("yaw_left", 0.0, 0.0, 0.35), + ("yaw_right", 0.0, 0.0, -0.35), + ("hard_stop", 0.0, 0.0, 0.0), + ("stop", 0.0, 0.0, 0.0), + ], +) +def test_dashboard_robot_jog_command_caps( + command: str, + linear_x: float, + linear_y: float, + angular_z: float, +) -> None: + assert dashboard.ROBOT_JOG_COMMANDS[command] == (linear_x, linear_y, angular_z) + + +def test_robot_motion_session_uses_sport_move_for_linear_jog(monkeypatch) -> None: + monkeypatch.setattr(dashboard, "HARD_STOP_REPEATS", 1) + session = object.__new__(dashboard._RobotMotionSession) + session.lock = threading.RLock() + session.mode = "connected" + sport_calls: list[str] = [] + move_calls: list[tuple[float, float, float]] = [] + joystick_calls: list[dict[str, float | int]] = [] + obstacle_calls: list[bool] = [] + session.connection = type( + "FakeConnection", + (), + {"set_obstacle_avoidance": lambda self, enabled: obstacle_calls.append(enabled)}, + )() + + session._sport = sport_calls.append + session._sport_move = lambda x, y, z: move_calls.append((x, y, z)) + session._send_joystick = lambda data: joystick_calls.append(data) + session._wait_pose = lambda: (0.0, 0.0, 0.0) + + result = session.jog(0.15, 0.0, 0.0, 0.0) + + assert obstacle_calls == [False, True] + assert sport_calls == ["BalanceStand", "StopMove"] + assert session.mode == "balance" + assert move_calls == [(0.15, 0.0, 0.0)] + assert joystick_calls == [{"lx": 0, "ly": 0, "rx": 0, "ry": 0}] + assert result["observed"] is True + + +def test_robot_motion_session_uses_sport_move_for_yaw_jog(monkeypatch) -> None: + monkeypatch.setattr(dashboard, "HARD_STOP_REPEATS", 1) + session = object.__new__(dashboard._RobotMotionSession) + session.lock = threading.RLock() + session.mode = "connected" + sport_calls: list[str] = [] + move_calls: list[tuple[float, float, float]] = [] + joystick_calls: list[dict[str, float | int]] = [] + + session._sport = sport_calls.append + session._sport_move = lambda x, y, z: move_calls.append((x, y, z)) + session._send_joystick = lambda data: joystick_calls.append(data) + session._wait_pose = lambda: (0.0, 0.0, 0.0) + + session.jog(0.0, 0.0, 0.30, 0.0) + + assert sport_calls == ["BalanceStand", "StopMove"] + assert session.mode == "balance" + assert move_calls == [(0.0, 0.0, 0.30)] + assert joystick_calls == [{"lx": 0, "ly": 0, "rx": 0, "ry": 0}] + + +def test_robot_motion_session_sport_move_builds_native_go2_request() -> None: + session = object.__new__(dashboard._RobotMotionSession) + session.rtc_topic = {"SPORT_MOD": "rt/api/sport/request"} + session.sport_cmd = {"Move": 1008} + requests: list[tuple[str, dict[str, object]]] = [] + session._request = lambda topic, data: requests.append((topic, data)) + + session._sport_move(0.15, -0.1, 0.3) + + assert requests == [ + ( + "rt/api/sport/request", + {"api_id": 1008, "parameter": {"x": 0.15, "y": -0.1, "z": 0.3}}, + ) + ] + + +def test_robot_motion_session_hard_stop_uses_stopmove_and_zero_joystick(monkeypatch) -> None: + monkeypatch.setattr(dashboard, "HARD_STOP_REPEATS", 1) + session = object.__new__(dashboard._RobotMotionSession) + session.lock = threading.RLock() + sport_calls: list[str] = [] + joystick_calls: list[dict[str, float | int]] = [] + session._sport = sport_calls.append + session._send_joystick = lambda data: joystick_calls.append(data) + + session.hard_stop() + + assert sport_calls == ["StopMove"] + assert joystick_calls == [{"lx": 0, "ly": 0, "rx": 0, "ry": 0}] + + +def test_response_status_code_extracts_go2_sport_response() -> None: + response = { + "type": "res", + "topic": "rt/api/sport/response", + "data": {"header": {"identity": {"api_id": 1008}}, "status": {"code": 0}}, + } + + assert dashboard._response_status_code(response) == 0 + assert dashboard._response_status_code({"data": {"status": {"code": 3203}}}) == 3203 + assert dashboard._response_status_code({}) is None + + + +def _sample_dashboard_qr_event(location_node_id: str = "COOLING_1") -> dict[str, Any]: + event = json.loads( + Path("examples/dogops/qr_cargo_event_sample.json").read_text(encoding="utf-8") + ) + payload = dict(event["qr_payload"]) + payload["location_node_id"] = location_node_id + event["qr_payload"] = payload + event["qr_payload_raw"] = json.dumps(payload, separators=(",", ":")) + event["robot_pose_at_detection"] = { + "frame": "map", + "x": 3.25, + "y": 0.25, + "yaw": 0.1, + } + return event + + +def test_dashboard_qr_event_api_persists_and_composes_overlay( + tmp_path, monkeypatch +) -> None: + def fail_robot_call(*_: object, **__: object) -> dict[str, object]: + raise AssertionError("QR event ingestion must not trigger robot control") + + monkeypatch.setattr(dashboard, "_run_robot_go_to", fail_robot_call) + monkeypatch.setattr(dashboard, "_publish_robot_jog", fail_robot_call) + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + state_before = (run_dir / "state.json").read_text(encoding="utf-8") + report_before = (run_dir / "report.json").read_text(encoding="utf-8") + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + missing_status, missing_result = _post_json( + f"{base_url}/api/qr/events", + _sample_dashboard_qr_event(), + ) + status, result = _post_json( + f"{base_url}/api/qr/events", + _sample_dashboard_qr_event(), + headers=_robot_headers(server), + ) + events = _get_json(f"{base_url}/api/qr/events") + latest = _get_json(f"{base_url}/api/qr/events/latest?limit=1") + event_id = result["event"]["event_id"] # type: ignore[index] + single = _get_json(f"{base_url}/api/qr/events/{event_id}") + map_data = _get_json(f"{base_url}/api/map") + html = (run_dir / "dashboard.html").read_text(encoding="utf-8") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert missing_status == 403 + assert missing_result["error"] == "map_authoring_forbidden" + assert status == 201 + assert result["event"]["action_policy"] == "report_only" # type: ignore[index] + assert (run_dir / "qr_events.jsonl").is_file() + assert events["count"] == 1 + assert latest["events"][0]["event_id"] == event_id # type: ignore[index] + assert single["event"]["event_id"] == event_id # type: ignore[index] + overlay = map_data["qr_cargo_events"][0] # type: ignore[index] + assert overlay["cargo_id"] == "BOX-20260527-018" + assert overlay["location_node_id"] == "COOLING_1" + assert overlay["map_position"] == { + "frame": "map", + "x": 3.25, + "y": 0.25, + "yaw": 0.1, + "source": "robot_pose_at_detection", + } + assert overlay["static_location_node_pose"]["source"] == "site_or_authoring" + assert overlay["pose_delta"]["distance_m"] > 0 + assert map_data["layers"]["qr"] is True # type: ignore[index] + assert "QR Cargo" in html + assert "BOX-20260527-018" in html + assert (run_dir / "state.json").read_text(encoding="utf-8") == state_before + assert (run_dir / "report.json").read_text(encoding="utf-8") == report_before + assert not (run_dir / "map_authoring.json").exists() + + +def test_dashboard_qr_promotion_stays_run_local_authoring(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + server = make_dashboard_server(run_dir, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + try: + _, result = _post_json( + f"{base_url}/api/qr/events", + _sample_dashboard_qr_event(location_node_id="WH03-A12-SHELF05"), + headers=_robot_headers(server), + ) + event_id = result["event"]["event_id"] # type: ignore[index] + status, promoted = _post_json( + f"{base_url}/api/qr/events/{event_id}/promote_to_package", + {}, + headers=_robot_headers(server), + ) + authoring = _get_json(f"{base_url}/api/map/authoring") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert status == 200 + assert promoted["ok"] is True + entity = authoring["entities"][0] # type: ignore[index] + assert entity["id"] == "BOX-20260527-018" + assert entity["kind"] == "package" + assert entity["source_id"] == event_id + assert entity["pose"]["source"] == "qr_cargo_event" + assert authoring["routes"] == [] diff --git a/dimos/experimental/dogops/test_detector.py b/dimos/experimental/dogops/test_detector.py new file mode 100644 index 0000000000..edba3e3372 --- /dev/null +++ b/dimos/experimental/dogops/test_detector.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest + +from dimos.experimental.dogops.detector import DogOpsTagDetector +from dimos.experimental.dogops.tag_registry import DogOpsTagRegistry + + +class _ImageLike: + def __init__(self, image, *, frame_id: str = "camera_optical") -> None: + self._image = image + self.frame_id = frame_id + + def to_opencv(self): + return self._image + + +def test_tag_registry_resolves_demo_entities(dogops_site) -> None: + registry = DogOpsTagRegistry(dogops_site) + + assert registry.require(104).entity_id == "PKG-104" + assert registry.require(41).entity_id == "COOLING_1" + assert registry.require(70).entity_id == "PORTAL_1" + + +def test_detector_resolves_simulated_tags(dogops_site) -> None: + detector = DogOpsTagDetector(dogops_site) + + detections = detector.detect_simulated([20, 101, 102, 999]) + by_tag = {detection.tag_id: detection for detection in detections} + + assert by_tag[20].entity_id == "INBOUND_DOCK" + assert by_tag[101].entity_id == "PKG-101" + assert by_tag[999].entity_id is None + assert by_tag[999].entity_kind is None + + +def test_detector_raises_clear_error_without_opencv(dogops_site) -> None: + cv2 = pytest.importorskip("cv2") + if hasattr(cv2, "aruco"): + pytest.skip("OpenCV aruco is available") + + detector = DogOpsTagDetector(dogops_site) + with pytest.raises(RuntimeError, match="aruco"): + detector.detect_array(None) + + +def test_detector_reads_generated_apriltag_with_opencv(dogops_site) -> None: + cv2 = pytest.importorskip("cv2") + np = pytest.importorskip("numpy") + if not hasattr(cv2, "aruco"): + pytest.skip("OpenCV aruco is unavailable") + aruco = cv2.aruco + dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11) + if hasattr(aruco, "generateImageMarker"): + marker = aruco.generateImageMarker(dictionary, 104, 240) + else: + marker = aruco.drawMarker(dictionary, 104, 240) + canvas = np.full((320, 320), 255, dtype=marker.dtype) + canvas[40:280, 40:280] = marker + + detector = DogOpsTagDetector(dogops_site) + detections = detector.detect_array(canvas) + + assert detections[0].tag_id == 104 + assert detections[0].entity_id == "PKG-104" + + +def test_detector_reads_dimos_image_like_frame_with_opencv(dogops_site) -> None: + cv2 = pytest.importorskip("cv2") + np = pytest.importorskip("numpy") + if not hasattr(cv2, "aruco"): + pytest.skip("OpenCV aruco is unavailable") + aruco = cv2.aruco + dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11) + if hasattr(aruco, "generateImageMarker"): + marker = aruco.generateImageMarker(dictionary, 104, 240) + else: + marker = aruco.drawMarker(dictionary, 104, 240) + canvas = np.full((320, 320), 255, dtype=marker.dtype) + canvas[40:280, 40:280] = marker + + detector = DogOpsTagDetector(dogops_site) + detections = detector.detect_dimos_image(_ImageLike(canvas)) + + assert detections[0].tag_id == 104 + assert detections[0].entity_id == "PKG-104" + assert detections[0].frame_id == "camera_optical" + assert detections[0].center_px == (159.5, 159.5) + assert detections[0].area_px is not None diff --git a/dimos/experimental/dogops/test_heatmap_runs.py b/dimos/experimental/dogops/test_heatmap_runs.py new file mode 100644 index 0000000000..383e9c3ceb --- /dev/null +++ b/dimos/experimental/dogops/test_heatmap_runs.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dimos.experimental.dogops.heatmap_runs import gather_heatmap_run, heatmap_snapshot_for_route_run + + +def _snapshot(cost: float) -> dict[str, object]: + return { + "ok": True, + "source": "DimOS live LCM topics", + "status": "receiving", + "costmap": { + "source": "DimOS live costmap", + "columns": 1, + "rows": 1, + "cells": [{"x": 1.0, "y": 2.0, "width": 0.5, "height": 0.5, "cost": cost}], + }, + "path": [], + "route": [], + "robot_pose": None, + "target": None, + } + + +def test_gather_heatmap_samples_for_duration_and_merges_max_cost(tmp_path) -> None: + reads = iter([_snapshot(0.8), _snapshot(0.4)]) + sleeps: list[float] = [] + + def read_snapshot() -> dict[str, object]: + return next(reads) + + def sleep(seconds: float) -> None: + sleeps.append(seconds) + + result = gather_heatmap_run( + tmp_path / ".dogops" / "runs" / "latest", + live_snapshot=_snapshot(0.2), + live_snapshot_reader=read_snapshot, + area_id="AISLE_1", + duration_s=0.2, + sample_interval_s=0.2, + sleep_fn=sleep, + ) + + assert result["ok"] is True + assert sleeps + cells = result["heatmap"]["costmap"]["cells"] # type: ignore[index] + assert cells[0]["cost"] == 0.8 + assert result["heatmap"]["area_id"] == "AISLE_1" # type: ignore[index] + assert heatmap_snapshot_for_route_run( + tmp_path / ".dogops" / "runs" / "latest", + str(result["route_run_id"]), + )["costmap"]["cells"][0]["cost"] == 0.8 diff --git a/dimos/experimental/dogops/test_map_authoring.py b/dimos/experimental/dogops/test_map_authoring.py new file mode 100644 index 0000000000..853cec0952 --- /dev/null +++ b/dimos/experimental/dogops/test_map_authoring.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import json + +import pytest + +from dimos.experimental.dogops.dashboard_static import build_map_data +from dimos.experimental.dogops.map_authoring import ( + EditableMapEntity, + EditableMapPoint, + EditableNoGoShape, + EditableRoute, + EditableRouteWaypoint, + EditableTagBinding, + MapAuthoringState, + export_authoring_yaml, + load_map_authoring, + publish_no_go_constraints, + replace_entity, + save_map_authoring, +) +from dimos.experimental.dogops.mission_engine import run_offline_simulation + + +def test_map_authoring_round_trips_empty_state(tmp_path) -> None: + run_dir = tmp_path / "latest" + authoring = MapAuthoringState(site_id="dogops_demo_site") + + path = save_map_authoring(run_dir, authoring) + loaded = load_map_authoring(run_dir) + + assert path.name == "map_authoring.json" + assert loaded.site_id == "dogops_demo_site" + assert loaded.entities == [] + assert loaded.no_go_shapes == [] + + +def test_map_authoring_rejects_duplicate_ids() -> None: + point = EditableMapPoint(x=1.0, y=2.0) + entity = EditableMapEntity(id="A", kind="checkpoint", label="A", pose=point) + + with pytest.raises(ValueError, match="duplicate entity id"): + MapAuthoringState(entities=[entity, entity]) + + +def test_map_authoring_rejects_duplicate_tag_bindings() -> None: + binding = EditableTagBinding( + tag_id=42, + entity_id="CHECKPOINT_A", + label="Checkpoint A", + binding_kind="checkpoint", + ) + + with pytest.raises(ValueError, match="duplicate tag id"): + MapAuthoringState(tag_bindings=[binding, binding]) + + +def test_build_map_data_applies_authoring_overrides(tmp_path) -> None: + run_dir = tmp_path / "latest" + state = run_offline_simulation(out=run_dir) + report = json.loads((run_dir / "report.json").read_text(encoding="utf-8")) + authoring = MapAuthoringState( + site_id="dogops_demo_site", + home=EditableMapPoint(x=-1.5, y=2.5), + entities=[ + EditableMapEntity( + id="COOLING_1", + kind="asset", + label="Edited Cooling", + pose=EditableMapPoint(x=9.0, y=8.0), + tag_id=141, + ), + EditableMapEntity( + id="CHECKPOINT_X", + kind="checkpoint", + label="Checkpoint X", + pose=EditableMapPoint(x=4.0, y=5.0), + ), + ], + no_go_shapes=[ + EditableNoGoShape( + id="NO_GO_EDIT", + label="Edited No-Go", + shape="rectangle", + points=[ + EditableMapPoint(x=7.0, y=7.0), + EditableMapPoint(x=8.0, y=8.0), + ], + ) + ], + routes=[ + EditableRoute( + id="ROUTE_EDIT", + label="Edited Route", + waypoints=[ + EditableRouteWaypoint( + id="WP1", + label="Waypoint 1", + pose=EditableMapPoint(x=4.0, y=5.0), + target_id="CHECKPOINT_X", + ) + ], + ) + ], + ) + + map_data = build_map_data( + state.model_dump(mode="json"), + report, + authoring=authoring.model_dump(mode="json"), + ) + + home = next(zone for zone in map_data["zones"] if zone["id"] == "HOME") + cooling = next(asset for asset in map_data["assets"] if asset["id"] == "COOLING_1") + checkpoint = next(zone for zone in map_data["zones"] if zone["id"] == "CHECKPOINT_X") + + assert home["x"] == -1.5 + assert home["y"] == 2.5 + assert cooling["display_name"] == "Edited Cooling" + assert cooling["tag_id"] == 141 + assert cooling["x"] == 9.0 + assert checkpoint["source"] == "dashboard_edit" + assert map_data["route"][0]["target_id"] == "CHECKPOINT_X" + assert map_data["authoring"]["selected_route_id"] is None + assert map_data["no_go_shapes"][0]["dimos_constraint_status"] == "not_supported" + assert map_data["authoring"]["entities"] == 2 + + +def test_incident_location_uses_authored_pose(tmp_path) -> None: + run_dir = tmp_path / "latest" + state = run_offline_simulation(out=run_dir) + report = json.loads((run_dir / "report.json").read_text(encoding="utf-8")) + authoring = { + "incident_locations": [ + { + "incident_id": "INC-001", + "pose": {"x": 6.0, "y": 6.5, "source": "dashboard_edit"}, + "evidence_observation_ids": ["OBS-003"], + } + ] + } + + map_data = build_map_data(state.model_dump(mode="json"), report, authoring=authoring) + incident = next(item for item in map_data["incidents"] if item["id"] == "INC-001") + + assert incident["x"] == 6.0 + assert incident["y"] == 6.5 + assert incident["source"] == "dashboard_edit" + + +def test_authoring_export_writes_run_local_yaml(tmp_path) -> None: + authoring = replace_entity( + MapAuthoringState(site_id="dogops_demo_site"), + EditableMapEntity( + id="CHECKPOINT_A", + kind="checkpoint", + label="Checkpoint A", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + ) + + exports = export_authoring_yaml(tmp_path, authoring) + + assert exports["site"].endswith("exports/site_authoring.yaml") + assert exports["mission"].endswith("exports/mission_authoring.yaml") + assert "CHECKPOINT_A" in (tmp_path / "exports" / "site_authoring.yaml").read_text( + encoding="utf-8" + ) + + +def test_authoring_export_includes_selected_route_mission_steps(tmp_path) -> None: + authoring = MapAuthoringState( + site_id="dogops_demo_site", + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP_A", + label="Waypoint A", + target_id="CHECKPOINT_A", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ) + + exports = export_authoring_yaml(tmp_path, authoring) + mission_yaml = (tmp_path / "exports" / "mission_authoring.yaml").read_text( + encoding="utf-8" + ) + + assert exports["mission"].endswith("mission_authoring.yaml") + assert "selected_route_id: ROUTE_A" in mission_yaml + assert "target_id: CHECKPOINT_A" in mission_yaml + + +def test_no_go_publish_marks_enabled_shapes_published_and_excludes_disabled( + monkeypatch, +) -> None: + calls: list[dict[str, object]] = [] + + def fake_run(*_: object, **kwargs: object) -> object: + calls.append(json.loads(str(kwargs["input"]))) + + class Result: + returncode = 0 + + return Result() + + monkeypatch.setattr("subprocess.run", fake_run) + authoring = MapAuthoringState( + site_id="dogops_demo_site", + no_go_shapes=[ + EditableNoGoShape( + id="NO_GO_ENABLED", + label="Enabled", + points=[EditableMapPoint(x=0.0, y=0.0), EditableMapPoint(x=1.0, y=1.0)], + enabled=True, + ), + EditableNoGoShape( + id="NO_GO_DISABLED", + label="Disabled", + points=[EditableMapPoint(x=2.0, y=2.0), EditableMapPoint(x=3.0, y=3.0)], + enabled=False, + ), + ], + ) + + published = publish_no_go_constraints(authoring, command="dogops-publisher") + + assert calls[0]["site_id"] == "dogops_demo_site" + assert [shape["id"] for shape in calls[0]["no_go_shapes"]] == ["NO_GO_ENABLED"] # type: ignore[index] + assert published.no_go_shapes[0].dimos_constraint_status == "published" + assert published.no_go_shapes[1].dimos_constraint_status == "not_supported" + + +def test_no_go_publish_marks_enabled_shapes_failed_on_command_failure() -> None: + authoring = MapAuthoringState( + no_go_shapes=[ + EditableNoGoShape( + id="NO_GO_FAIL", + label="Fail", + points=[EditableMapPoint(x=0.0, y=0.0), EditableMapPoint(x=1.0, y=1.0)], + ) + ] + ) + + failed = publish_no_go_constraints(authoring, command="false") + + assert failed.no_go_shapes[0].dimos_constraint_status == "failed" diff --git a/dimos/experimental/dogops/test_mission_engine.py b/dimos/experimental/dogops/test_mission_engine.py new file mode 100644 index 0000000000..51621d0742 --- /dev/null +++ b/dimos/experimental/dogops/test_mission_engine.py @@ -0,0 +1,21 @@ +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.models import IncidentState, PackageState, WorkOrderState + + +def test_offline_simulation_closes_blocked_cooling_and_leaves_missing_package(tmp_path) -> None: + state = run_offline_simulation(out=tmp_path / "run") + + incidents = {incident.id: incident for incident in state.incidents} + work_orders = {work_order.id: work_order for work_order in state.work_orders} + + assert state.run.state == "done" + assert incidents["INC-001"].state == IncidentState.resolved + assert incidents["INC-001"].type == "blocked_cooling" + assert incidents["INC-001"].related_package_id == "PKG-104" + assert work_orders["WO-001"].state == WorkOrderState.verified_closed + assert incidents["INC-002"].state == IncidentState.open + assert incidents["INC-002"].related_package_id == "PKG-103" + assert state.package_statuses["PKG-103"].state == PackageState.missing + assert state.package_statuses["PKG-104"].observed_zone_id == "QA_HOLD" + assert state.nav_summary is not None + assert state.nav_summary.waypoints_reached == 4 diff --git a/dimos/experimental/dogops/test_nav_eval.py b/dimos/experimental/dogops/test_nav_eval.py new file mode 100644 index 0000000000..0ae1c6372d --- /dev/null +++ b/dimos/experimental/dogops/test_nav_eval.py @@ -0,0 +1,52 @@ +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.models import NavAction, NavEvent +from dimos.experimental.dogops.nav_eval import DogOpsNavEvalModule, summarize_nav_events + + +def test_summarize_nav_events_records_retries_and_tag_recovery() -> None: + events = [ + NavEvent( + id="NAV-001", + run_id="run-1", + ts=1.0, + action=NavAction.goto, + target_id="HOME", + success=True, + elapsed_s=4.0, + ), + NavEvent( + id="NAV-002", + run_id="run-1", + ts=2.0, + action=NavAction.goto, + target_id="COOLING_1", + success=True, + elapsed_s=10.0, + retries=1, + note="tag search recovery used", + ), + ] + + summary = summarize_nav_events("run-1", events) + + assert summary.waypoints_total == 2 + assert summary.waypoints_reached == 2 + assert summary.retries_total == 1 + assert summary.tag_reacquisition_attempts == 1 + assert summary.tag_reacquisition_successes == 1 + assert summary.tag_reacquisition_rate == 1.0 + assert summary.route_targets == 2 + assert summary.unique_targets_reached == 2 + assert summary.route_coverage == 1.0 + assert summary.guided_fallback_used is False + assert summary.worst_target_id == "COOLING_1" + + +def test_nav_eval_module_summarizes_run_directory(tmp_path) -> None: + run_offline_simulation(out=tmp_path / "latest") + module = DogOpsNavEvalModule() + + summary = module.summarize_run(tmp_path / "latest") + + assert '"route_coverage": 1.0' in summary + assert '"waypoints_reached": 4' in summary diff --git a/dimos/experimental/dogops/test_observation_module.py b/dimos/experimental/dogops/test_observation_module.py new file mode 100644 index 0000000000..5defab31db --- /dev/null +++ b/dimos/experimental/dogops/test_observation_module.py @@ -0,0 +1,69 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +import pytest + +from dimos.experimental.dogops.observation_module import DogOpsObservationModule + + +class _ImageLike: + def __init__(self, image, *, frame_id: str = "camera_optical") -> None: + self._image = image + self.frame_id = frame_id + + def to_opencv(self): + return self._image + + +@contextmanager +def _observation_module() -> Iterator[DogOpsObservationModule]: + module = DogOpsObservationModule() + try: + yield module + finally: + module.stop() + + +def test_observation_module_builds_observations_from_simulated_tags() -> None: + with _observation_module() as module: + observations = module.observe_simulated_tags([20, 101, 102], zone_id="INBOUND_DOCK") + + assert [obs.tag_id for obs in observations] == [20, 101, 102] + assert observations[0].entity_id == "INBOUND_DOCK" + assert observations[1].entity_id == "PKG-101" + assert observations[1].facts["detection_source"] == "simulation" + + +def test_observation_module_reports_stream_fallback() -> None: + with _observation_module() as module: + status = module.image_stream_status() + + assert status["ok"] is False + assert status["mode"] == "not_subscribed" + + +def test_observation_module_scans_latest_camera_frame() -> None: + cv2 = pytest.importorskip("cv2") + np = pytest.importorskip("numpy") + if not hasattr(cv2, "aruco"): + pytest.skip("OpenCV aruco is unavailable") + aruco = cv2.aruco + dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11) + if hasattr(aruco, "generateImageMarker"): + marker = aruco.generateImageMarker(dictionary, 104, 240) + else: + marker = aruco.drawMarker(dictionary, 104, 240) + canvas = np.full((320, 320), 255, dtype=marker.dtype) + canvas[40:280, 40:280] = marker + + with _observation_module() as module: + module.ingest_camera_image(_ImageLike(canvas)) + status = module.image_stream_status() + observations = module.observe_latest_image(zone_id="QA_HOLD") + + assert status["ok"] is True + assert status["mode"] == "latest_frame" + assert observations[0].tag_id == 104 + assert observations[0].entity_id == "PKG-104" + assert observations[0].facts["detection_source"] == "dimos.color_image" + assert observations[0].facts["frame_id"] == "camera_optical" diff --git a/dimos/experimental/dogops/test_qr_cargo.py b/dimos/experimental/dogops/test_qr_cargo.py new file mode 100644 index 0000000000..91c832bc54 --- /dev/null +++ b/dimos/experimental/dogops/test_qr_cargo.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dimos.experimental.dogops.qr_cargo import ( + append_qr_event, + get_latest_qr_events, + load_qr_events, + validate_qr_payload, +) + + +def _sample_event() -> dict[str, object]: + return json.loads( + Path("examples/dogops/qr_cargo_event_sample.json").read_text(encoding="utf-8") + ) + + +def test_qr_payload_validation_accepts_valid_cargo_payload() -> None: + event = _sample_event() + ok, errors = validate_qr_payload(event["qr_payload"]) # type: ignore[arg-type] + + assert ok is True + assert errors == [] + + +@pytest.mark.parametrize("field", ["warehouse_id", "location_node_id", "cargo_id"]) +def test_qr_payload_validation_rejects_missing_required_fields(field: str) -> None: + event = _sample_event() + payload = dict(event["qr_payload"]) # type: ignore[arg-type] + payload.pop(field) + + ok, errors = validate_qr_payload(payload) + + assert ok is False + assert f"qr_payload.{field} is required" in errors + + +def test_qr_event_append_loads_latest_and_writes_state(tmp_path) -> None: + run_dir = tmp_path / "latest" + event = _sample_event() + + normalized = append_qr_event(run_dir, event) + events = load_qr_events(run_dir) + latest = get_latest_qr_events(run_dir) + + assert normalized["event_id"] + assert normalized["action_policy"] == "report_only" + assert (run_dir / "qr_events.jsonl").is_file() + assert (run_dir / "qr_cargo_state.json").is_file() + assert events == [normalized] + assert latest == [normalized] diff --git a/dimos/experimental/dogops/test_report.py b/dimos/experimental/dogops/test_report.py new file mode 100644 index 0000000000..d264d4e00c --- /dev/null +++ b/dimos/experimental/dogops/test_report.py @@ -0,0 +1,47 @@ +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.report import build_report_data, render_report_markdown + + +def test_report_contains_closed_loop_facts(tmp_path) -> None: + state = run_offline_simulation(out=tmp_path / "run") + + data = build_report_data(state) + report = render_report_markdown(state) + + assert data["manifest_exceptions"] == 2 + assert data["incidents_opened"] == 2 + assert data["work_orders_verified_closed"] == 1 + assert data["checkpoints_total"] == 4 + assert data["checkpoints_verified"] == 4 + assert data["checkpoint_verifications"] == [ + { + "target_id": "HOME", + "expected_tag_id": 10, + "verified": True, + "observation_id": "OBS-001", + }, + { + "target_id": "INBOUND_DOCK", + "expected_tag_id": 20, + "verified": True, + "observation_id": "OBS-002", + }, + { + "target_id": "COOLING_1", + "expected_tag_id": 41, + "verified": True, + "observation_id": "OBS-003", + }, + { + "target_id": "QA_HOLD", + "expected_tag_id": 30, + "verified": True, + "observation_id": "OBS-005", + }, + ] + assert "PKG-104 wrong zone and blocking COOLING_1" in report + assert "INC-001 P1 blocked_cooling" in report + assert "PKG-103 missing_package" in report + assert "What changed: PKG-104 moved from COOLING_1/RACK_ROW_A to QA_HOLD" in report + assert "Nav: 4/4 waypoints reached, 1 tag-search recovery, 0 safety stops" in report + assert "Checkpoints: 4/4 tag sign-ins verified" in report diff --git a/dimos/experimental/dogops/test_route_executor.py b/dimos/experimental/dogops/test_route_executor.py new file mode 100644 index 0000000000..32b785fa37 --- /dev/null +++ b/dimos/experimental/dogops/test_route_executor.py @@ -0,0 +1,1053 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dimos.experimental.dogops.map_authoring import ( + EditableMapPoint, + EditableRoute, + EditableRouteWaypoint, + MapAuthoringState, + save_map_authoring, +) +from dimos.experimental.dogops.gemini_vision import GeminiImageInspection, GeminiVisionResult +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.route_actions import EditableRouteAction +from dimos.experimental.dogops.route_executor import ( + CallableGoalPublisher, + DogOpsRouteExecutor, + RouteExecutionError, + load_route_execution, + request_route_stop, + route_feedback_from_snapshot, + route_execution_lock, + save_route_execution, +) +from dimos.experimental.dogops.route_run_store import RouteRunStore + + +def _route(route_id: str = "ROUTE_A") -> EditableRoute: + return EditableRoute( + id=route_id, + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="One", + target_id="CHECKPOINT_1", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + EditableRouteWaypoint( + id="WP-2", + label="Two", + target_id="CHECKPOINT_2", + pose=EditableMapPoint(x=2.0, y=2.5), + ), + ], + ) + + +def _save_authoring(tmp_path, *, selected_route_id: str | None = "ROUTE_A") -> None: + save_map_authoring( + tmp_path, + MapAuthoringState( + selected_route_id=selected_route_id, + routes=[_route("ROUTE_A"), _route("ROUTE_B")], + ), + ) + + +def test_dry_run_resolves_selected_route_without_publishing(tmp_path) -> None: + _save_authoring(tmp_path, selected_route_id="ROUTE_A") + published: list[tuple[float, float]] = [] + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher( + lambda x, y, z, frame: published.append((x, y)) + ), + ) + + state = executor.follow_route(dry_run=True) + + assert state.state == "completed" + assert state.route_id == "ROUTE_A" + assert state.transport == "dry_run" + assert [event.waypoint_id for event in state.events] == ["WP-1", "WP-2"] + assert [event.state for event in state.events] == ["queued", "queued"] + assert published == [] + assert load_route_execution(tmp_path).route_id == "ROUTE_A" + + +def test_explicit_route_id_overrides_selected_route(tmp_path) -> None: + _save_authoring(tmp_path, selected_route_id="ROUTE_A") + + state = DogOpsRouteExecutor(tmp_path).follow_route("ROUTE_B", dry_run=True) + + assert state.route_id == "ROUTE_B" + + +def test_missing_and_empty_routes_are_rejected(tmp_path) -> None: + save_map_authoring(tmp_path, MapAuthoringState(routes=[])) + executor = DogOpsRouteExecutor(tmp_path) + + with pytest.raises(RouteExecutionError, match="no route_id"): + executor.follow_route(dry_run=True) + + save_map_authoring( + tmp_path, + MapAuthoringState( + selected_route_id="EMPTY", + routes=[EditableRoute(id="EMPTY", label="Empty")], + ), + ) + with pytest.raises(RouteExecutionError, match="no waypoints"): + executor.follow_route(dry_run=True) + + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id=None, routes=[_route("ROUTE_A")]), + ) + with pytest.raises(RouteExecutionError, match="no route_id"): + executor.follow_route(dry_run=True) + + +def test_fake_publisher_receives_goals_in_order_and_waits_for_odom(tmp_path) -> None: + _save_authoring(tmp_path) + published: list[tuple[float, float, str]] = [] + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + published.append((x, y, frame)) + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + def snapshot() -> dict[str, object]: + return { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + } + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=snapshot, + waypoint_timeout_s=0.1, + sleep_fn=lambda _: None, + ) + + state = executor.follow_route() + + assert state.state == "completed" + assert state.waypoints_reached == 2 + assert published == [(1.0, 2.0, "world"), (2.0, 2.5, "world")] + assert [event.state for event in state.events] == [ + "queued", + "sent", + "reached", + "queued", + "sent", + "reached", + ] + + +def test_route_actions_record_timeline_and_placeholder_evidence(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_ACTIONS", + label="Route Actions", + waypoints=[ + EditableRouteWaypoint( + id="WP-ACTION", + label="Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="SCAN-TAGS", + kind="scan_tags", + args={"expected": [41]}, + ), + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"target": "COOLING_1"}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_ACTIONS", routes=[route]), + ) + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=lambda: { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + }, + sleep_fn=lambda _: None, + ) + + state = executor.follow_route() + + assert state.state == "completed" + assert state.route_run_id + action_events = [event for event in state.events if event.kind == "action"] + assert [event.action_id for event in action_events] == [ + "SCAN-TAGS", + "SCAN-TAGS", + "CAPTURE", + "CAPTURE", + ] + assert action_events[-1].payload["source"] == "demo_placeholder" + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id) + evidence_kinds = {item["kind"] for item in evidence} + assert {"tag_detection", "image"} <= evidence_kinds + image_evidence = [item for item in evidence if item["kind"] == "image"][0] + assert image_evidence["metadata"]["source"] == "demo_placeholder" + assert image_evidence["mime_type"] == "image/png" + assert (tmp_path / "route_runs" / state.route_run_id / "evidence" / "WP-ACTION-CAPTURE.png").exists() + + +def test_capture_image_uses_configured_go2_image(tmp_path) -> None: + source_image = tmp_path / "go2-frame.jpg" + source_image.write_bytes(b"fake-jpeg") + route = EditableRoute( + id="ROUTE_IMAGE", + label="Route Image", + waypoints=[ + EditableRouteWaypoint( + id="WP-IMAGE", + label="Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image)}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_IMAGE", routes=[route]), + ) + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + state = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=lambda: { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + }, + sleep_fn=lambda _: None, + ).follow_route() + + assert state.route_run_id + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id) + image_evidence = [item for item in evidence if item["kind"] == "image"][0] + assert image_evidence["metadata"]["source"] == "go2_camera_configured" + copied_path = tmp_path / "route_runs" / state.route_run_id / "evidence" / "WP-IMAGE-CAPTURE.jpg" + assert copied_path.read_bytes() == b"fake-jpeg" + + +def test_capture_image_uses_live_camera_handler_when_available(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_LIVE_IMAGE", + label="Route Live Image", + waypoints=[ + EditableRouteWaypoint( + id="WP-LIVE-IMAGE", + label="Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_LIVE_IMAGE", routes=[route]), + ) + + def capture_image(context: dict[str, object]) -> dict[str, object]: + evidence_dir = context["evidence_dir"] + assert isinstance(evidence_dir, Path) + path = evidence_dir / "live-camera.png" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"live-camera") + return { + "path": str(path), + "source": "go2_camera_live", + "mime_type": "image/png", + "metadata": { + "camera_frame_id": "front-1", + "camera_frame_age_s": 0.25, + }, + } + + state = DogOpsRouteExecutor( + tmp_path, + capture_image_handler=capture_image, + ).follow_route(dry_run=True) + + assert state.route_run_id + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id) + image_evidence = [item for item in evidence if item["kind"] == "image"][0] + assert image_evidence["metadata"]["source"] == "go2_camera_live" + assert image_evidence["metadata"]["camera_frame_id"] == "front-1" + assert image_evidence["metadata"]["camera_frame_age_s"] == 0.25 + assert Path(image_evidence["path"]).read_bytes() == b"live-camera" + + +def test_qr_route_action_uses_scan_zone_handler_when_target_is_zone(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_CAMERA_QR", + label="Route Camera QR", + waypoints=[ + EditableRouteWaypoint( + id="WP-QA", + label="QA", + target_id="QA_HOLD", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="SCAN-QR", + kind="scan_qr", + args={"expected": ["PKG-104"]}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_CAMERA_QR", routes=[route]), + ) + scanned_zones: list[str] = [] + + def scan_zone(zone_id: str) -> str: + scanned_zones.append(zone_id) + return json.dumps( + { + "ok": True, + "skill": "scan_zone", + "zone_id": zone_id, + "visible_tag_ids": [104], + "package_ids": ["PKG-104"], + "source": "camera", + "evidence_observation_ids": ["CAM-1"], + } + ) + + state = DogOpsRouteExecutor(tmp_path, scan_zone_handler=scan_zone).follow_route(dry_run=True) + + assert state.state == "completed" + assert scanned_zones == ["QA_HOLD"] + completed = [event for event in state.events if event.action_id == "SCAN-QR"][-1] + assert completed.payload["source"] == "scan_zone" + assert completed.payload["scan_zone_source"] == "camera" + assert completed.payload["detected_payloads"] == ["PKG-104"] + assert completed.payload["detected_tag_ids"] == [104] + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id or "") + assert evidence[0]["metadata"]["source"] == "scan_zone" + assert evidence[0]["metadata"]["scan_zone_source"] == "camera" + + +def test_qr_route_action_fails_when_scan_zone_handler_fails(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_CAMERA_QR_FAIL", + label="Route Camera QR Fail", + waypoints=[ + EditableRouteWaypoint( + id="WP-QA", + label="QA", + target_id="QA_HOLD", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="SCAN-QR", + kind="scan_qr", + args={"expected": ["PKG-104"]}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_CAMERA_QR_FAIL", routes=[route]), + ) + + def scan_zone(zone_id: str) -> dict[str, object]: + return { + "ok": False, + "skill": "scan_zone", + "zone_id": zone_id, + "error": "camera_detector_unavailable", + } + + state = DogOpsRouteExecutor(tmp_path, scan_zone_handler=scan_zone).follow_route(dry_run=True) + + assert state.state == "failed" + assert state.last_error == "QR scan zone failed: camera_detector_unavailable" + failed = [event for event in state.events if event.action_id == "SCAN-QR"][-1] + assert failed.state == "failed" + assert failed.payload["source"] == "scan_zone" + assert failed.payload["scan_zone"]["error"] == "camera_detector_unavailable" + + +def test_gemini_inspect_without_api_key_skips_without_analysis_evidence(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + source_image = tmp_path / "go2-frame.jpg" + source_image.write_bytes(b"fake-jpeg") + route = EditableRoute( + id="ROUTE_GEMINI_SKIP", + label="Route Gemini Skip", + waypoints=[ + EditableRouteWaypoint( + id="WP-GEMINI", + label="Gemini Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image)}, + ), + EditableRouteAction( + id="GEMINI", + kind="gemini_inspect_image", + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_GEMINI_SKIP", routes=[route]), + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + assert state.state == "completed" + gemini_events = [event for event in state.events if event.action_id == "GEMINI"] + assert [event.state for event in gemini_events] == ["started", "skipped"] + assert gemini_events[-1].payload["status"] == "gemini_unavailable" + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id or "") + assert {item["kind"] for item in evidence} == {"image"} + + +def test_gemini_inspect_records_analysis_evidence_with_baseline(tmp_path, monkeypatch) -> None: + source_image = tmp_path / "go2-frame.jpg" + source_image.write_bytes(b"fake-jpeg") + baseline_route = EditableRoute( + id="ROUTE_GEMINI", + label="Route Gemini", + waypoints=[ + EditableRouteWaypoint( + id="WP-GEMINI", + label="Gemini Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image), "target": "COOLING_1"}, + ) + ], + ) + ], + ) + inspect_route = baseline_route.model_copy(deep=True) + inspect_route.waypoints[0].actions.append( + EditableRouteAction( + id="GEMINI", + kind="gemini_inspect_image", + args={"target": "COOLING_1"}, + ) + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_GEMINI", routes=[baseline_route]), + ) + first = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_GEMINI", routes=[inspect_route]), + ) + + def fake_inspect(**kwargs): + assert kwargs["baseline_image_path"] + assert kwargs["baseline_evidence_id"] + return GeminiVisionResult( + ok=True, + status="completed", + message="No visible change", + model=kwargs["model"], + inspection=GeminiImageInspection( + ok=True, + summary="No visible change", + current_description="Cooling area is clear.", + baseline_description="Cooling area was clear.", + changed=False, + change_summary="No material change.", + change_type="no_change", + severity="info", + confidence=0.91, + observations=["clearance visible"], + possible_incident=False, + recommended_action="Continue route.", + ), + ) + + monkeypatch.setattr( + "dimos.experimental.dogops.gemini_vision.inspect_images_with_gemini", + fake_inspect, + ) + + second = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + assert first.route_run_id + assert second.route_run_id + evidence = RouteRunStore(tmp_path).route_run_evidence(second.route_run_id) + analysis = [item for item in evidence if item["kind"] == "gemini_vision_analysis"] + assert len(analysis) == 1 + assert analysis[0]["metadata"]["baseline_route_run_id"] == first.route_run_id + assert analysis[0]["metadata"]["baseline_match"] == "same_route_waypoint" + assert analysis[0]["metadata"]["changed"] is False + assert (tmp_path / "route_runs" / second.route_run_id / "evidence" / "WP-GEMINI-GEMINI-gemini.json").exists() + gemini_events = [event for event in second.events if event.action_id == "GEMINI"] + assert gemini_events[-1].payload["analysis_evidence_id"] == analysis[0]["evidence_id"] + + +def test_gemini_inspect_can_analyze_default_placeholder_capture(tmp_path, monkeypatch) -> None: + route = EditableRoute( + id="ROUTE_GEMINI_PLACEHOLDER", + label="Route Gemini Placeholder", + waypoints=[ + EditableRouteWaypoint( + id="WP-GEMINI-PLACEHOLDER", + label="Gemini Waypoint", + target_id="PLACEHOLDER_TARGET", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"target": "PLACEHOLDER_TARGET"}, + ), + EditableRouteAction( + id="GEMINI", + kind="gemini_inspect_image", + args={"target": "PLACEHOLDER_TARGET"}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_GEMINI_PLACEHOLDER", routes=[route]), + ) + + gemini_calls: list[dict[str, object]] = [] + + def fake_inspect(**kwargs): + gemini_calls.append(kwargs) + return GeminiVisionResult( + ok=True, + status="completed", + message="Placeholder inspected", + model=kwargs["model"], + inspection=GeminiImageInspection( + ok=True, + summary="Placeholder inspected", + current_description="Demo placeholder image.", + changed=False, + change_summary="No baseline available.", + change_type="unclear", + severity="info", + confidence=0.5, + observations=["demo placeholder"], + possible_incident=False, + recommended_action="Use real camera evidence for final validation.", + ), + ) + + monkeypatch.setattr( + "dimos.experimental.dogops.gemini_vision.inspect_images_with_gemini", + fake_inspect, + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id or "") + image = [item for item in evidence if item["kind"] == "image"][0] + analysis = [item for item in evidence if item["kind"] == "gemini_vision_analysis"][0] + assert gemini_calls + assert gemini_calls[0]["current_mime_type"] == "image/png" + assert str(gemini_calls[0]["current_image_path"]).endswith(".png") + assert image["mime_type"] == "image/png" + assert image["metadata"]["source"] == "demo_placeholder" + assert analysis["metadata"]["summary"] == "Placeholder inspected" + + +def test_dry_run_executes_route_actions_and_records_qr_evidence(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_QR", + label="Route QR", + waypoints=[ + EditableRouteWaypoint( + id="WP-QR", + label="QR Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="SCAN-QR", + kind="scan_qr", + args={"expected": ["PKG-101"]}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_QR", routes=[route]), + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + assert state.state == "completed" + action_events = [event for event in state.events if event.kind == "action"] + assert [event.state for event in action_events] == ["started", "completed"] + assert action_events[-1].payload["detected_payloads"] == ["PKG-101"] + assert RouteRunStore(tmp_path).route_run_detail(state.route_run_id or "")["actions_completed"] == 1 + evidence = RouteRunStore(tmp_path).route_run_evidence(state.route_run_id or "") + assert evidence[0]["kind"] == "qr_detection" + assert evidence[0]["metadata"]["detected_payloads"] == ["PKG-101"] + + +def test_optional_action_failure_records_and_continues(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_OPTIONAL", + label="Route Optional", + waypoints=[ + EditableRouteWaypoint( + id="WP-OPTIONAL", + label="Optional Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="OPTIONAL-QR", + kind="scan_qr", + required=False, + ), + EditableRouteAction( + id="SCAN-TAGS", + kind="scan_tags", + args={"expected": [41]}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_OPTIONAL", routes=[route]), + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + assert state.state == "completed" + action_events = [event for event in state.events if event.kind == "action"] + assert [(event.action_id, event.state) for event in action_events] == [ + ("OPTIONAL-QR", "started"), + ("OPTIONAL-QR", "failed"), + ("SCAN-TAGS", "started"), + ("SCAN-TAGS", "completed"), + ] + route_run = RouteRunStore(tmp_path).route_run_detail(state.route_run_id or "") + assert route_run["state"] == "completed" + assert route_run["actions_completed"] == 1 + + +def test_dry_run_required_action_failure_stays_failed(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_REQUIRED_FAIL", + label="Route Required Fail", + waypoints=[ + EditableRouteWaypoint( + id="WP-REQUIRED", + label="Required Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[EditableRouteAction(id="REQUIRED-QR", kind="scan_qr")], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_REQUIRED_FAIL", routes=[route]), + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + assert state.state == "failed" + assert state.last_error == "QR scan configured without expected payloads" + assert load_route_execution(tmp_path).state == "failed" + route_run = RouteRunStore(tmp_path).route_run_detail(state.route_run_id or "") + assert route_run["state"] == "failed" + assert route_run["actions_completed"] == 0 + + +def test_skipped_action_state_is_preserved(tmp_path, monkeypatch) -> None: + route = EditableRoute( + id="ROUTE_SKIPPED", + label="Route Skipped", + waypoints=[ + EditableRouteWaypoint( + id="WP-SKIPPED", + label="Skipped Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[EditableRouteAction(id="WAIT", kind="wait")], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_SKIPPED", routes=[route]), + ) + + from dimos.experimental.dogops.route_actions import RouteActionResult + import dimos.experimental.dogops.route_executor as route_executor_module + + monkeypatch.setattr( + route_executor_module, + "execute_route_action", + lambda *_, **__: RouteActionResult(ok=True, state="skipped", note="not needed"), + ) + + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + skipped_event = [event for event in state.events if event.action_id == "WAIT"][-1] + assert skipped_event.state == "skipped" + route_events = RouteRunStore(tmp_path).route_run_events(state.route_run_id or "") + assert route_events[-1]["state"] == "skipped" + + +def test_route_action_exception_marks_route_failed(tmp_path) -> None: + route = EditableRoute( + id="ROUTE_BAD_ACTION", + label="Route Bad Action", + waypoints=[ + EditableRouteWaypoint( + id="WP-BAD", + label="Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="BAD-SCAN", + kind="scan_tags", + args={"expected": ["not-an-int"]}, + ), + ], + ) + ], + ) + save_map_authoring( + tmp_path, + MapAuthoringState(selected_route_id="ROUTE_BAD_ACTION", routes=[route]), + ) + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=lambda: { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + }, + sleep_fn=lambda _: None, + ) + + state = executor.follow_route() + + assert state.state == "failed" + assert state.route_run_id + assert "invalid literal" in (state.last_error or "") + assert load_route_execution(tmp_path).state == "failed" + failed_action = [event for event in state.events if event.action_id == "BAD-SCAN"][-1] + assert failed_action.state == "failed" + assert failed_action.payload["error"] == "ValueError" + route_run = RouteRunStore(tmp_path).route_run_detail(state.route_run_id) + assert route_run is not None + assert route_run["state"] == "failed" + + +def test_stale_odom_causes_timeout_failure(tmp_path) -> None: + _save_authoring(tmp_path) + now = 0.0 + + def time_fn() -> float: + return now + + def sleep_fn(seconds: float) -> None: + nonlocal now + now += seconds + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(lambda *_: {"accepted": True}), + live_snapshot_reader=lambda: { + "robot_pose": {"x": 0.0, "y": 0.0}, + "target": {"x": 1.0, "y": 2.0}, + "topics": {"odom": {"age_s": 99.0}}, + }, + waypoint_timeout_s=0.25, + poll_interval_s=0.1, + time_fn=time_fn, + sleep_fn=sleep_fn, + ) + + state = executor.follow_route() + + assert state.state == "failed" + assert state.last_error == "odom stale: 99.0s" + assert state.events[-1].state == "timeout" + + +def test_stop_request_interrupts_execution(tmp_path) -> None: + _save_authoring(tmp_path) + now = 0.0 + stopped = False + + def time_fn() -> float: + return now + + def sleep_fn(seconds: float) -> None: + nonlocal now, stopped + now += seconds + if not stopped: + stopped = True + state = load_route_execution(tmp_path) + state.stop_requested = True + save_route_execution(tmp_path, state) + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(lambda *_: {"accepted": True}), + live_snapshot_reader=lambda: { + "robot_pose": {"x": -10.0, "y": -10.0}, + "target": {"x": 1.0, "y": 2.0}, + "topics": {"odom": {"age_s": 0.1}}, + }, + waypoint_timeout_s=2.0, + poll_interval_s=0.1, + time_fn=time_fn, + sleep_fn=sleep_fn, + ) + + state = executor.follow_route() + + assert state.state == "stopped" + assert state.stop_requested is True + assert state.events[-1].state == "stopped" + + +def test_completed_route_appends_nav_events_and_report(tmp_path) -> None: + run_offline_simulation(out=tmp_path) + _save_authoring(tmp_path) + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=lambda: { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + }, + sleep_fn=lambda _: None, + ) + + state = executor.follow_route() + + assert state.state == "completed" + report = (tmp_path / "report.md").read_text(encoding="utf-8") + assert "live route ROUTE_A: reached CHECKPOINT_1 via fake_nav" in report + stored = (tmp_path / "nav_events.jsonl").read_text(encoding="utf-8") + assert "CHECKPOINT_1" in stored + assert "CHECKPOINT_2" in stored + assert '"error_m": 0.0' in stored + assert '"guided": false' in stored + assert '"retries": 0' in stored + + +def test_repeated_route_runs_append_distinct_nav_evidence(tmp_path) -> None: + run_offline_simulation(out=tmp_path) + _save_authoring(tmp_path) + current_goal = {"x": 0.0, "y": 0.0} + + def publish(x: float, y: float, z: float, frame: str) -> dict[str, object]: + current_goal.update({"x": x, "y": y}) + return {"accepted": True} + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(publish, transport_name="fake_nav"), + live_snapshot_reader=lambda: { + "robot_pose": {"x": current_goal["x"], "y": current_goal["y"]}, + "target": {"x": current_goal["x"], "y": current_goal["y"]}, + "topics": {"odom": {"age_s": 0.1}}, + }, + sleep_fn=lambda _: None, + ) + + first = executor.follow_route() + second = executor.follow_route() + + assert first.state == "completed" + assert second.state == "completed" + stored = (tmp_path / "nav_events.jsonl").read_text(encoding="utf-8") + assert stored.count("live route ROUTE_A: reached CHECKPOINT_1 via fake_nav") == 2 + + +def test_no_progress_is_recorded_before_timeout(tmp_path) -> None: + _save_authoring(tmp_path) + now = 0.0 + + def sleep_fn(seconds: float) -> None: + nonlocal now + now += seconds + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(lambda *_: {"accepted": True}), + live_snapshot_reader=lambda: { + "robot_pose": {"x": -10.0, "y": -10.0}, + "target": {"x": 1.0, "y": 2.0}, + "topics": {"odom": {"age_s": 0.1}}, + }, + waypoint_timeout_s=5.0, + no_progress_timeout_s=0.3, + poll_interval_s=0.1, + time_fn=lambda: now, + sleep_fn=sleep_fn, + ) + + state = executor.follow_route() + + assert state.state == "failed" + assert state.last_error == "no progress toward waypoint" + assert state.events[-1].note == "no progress toward waypoint" + + +def test_unconfirmed_goal_does_not_mark_waypoint_reached(tmp_path) -> None: + _save_authoring(tmp_path) + + executor = DogOpsRouteExecutor( + tmp_path, + goal_publisher=CallableGoalPublisher(lambda *_: {"accepted": True}), + live_snapshot_reader=lambda: { + "robot_pose": {"x": 1.0, "y": 2.0}, + "topics": {"odom": {"age_s": 0.1}}, + }, + waypoint_timeout_s=0.1, + sleep_fn=lambda _: None, + ) + + state = executor.follow_route() + + assert state.state == "failed" + assert "unconfirmed" in state.last_error + + +def test_concurrent_route_start_is_rejected(tmp_path) -> None: + _save_authoring(tmp_path) + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + state.state = "running" + save_route_execution(tmp_path, state) + + with route_execution_lock(tmp_path): + with pytest.raises(RouteExecutionError, match="already running"): + DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + + # The failed attempt must not leave behind a stale lock. + state.state = "completed" + save_route_execution(tmp_path, state) + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + assert state.state == "completed" + + +def test_route_feedback_from_snapshot_handles_odom_age() -> None: + odom, age_s = route_feedback_from_snapshot( + {"robot_pose": {"x": "1.5", "y": 2}, "topics": {"odom": {"age_s": 0.4}}} + ) + + assert odom == {"x": 1.5, "y": 2.0} + assert age_s == 0.4 + + +def test_request_route_stop_sets_server_side_stop_flag(tmp_path) -> None: + _save_authoring(tmp_path) + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + state.state = "running" + state.stop_requested = False + save_route_execution(tmp_path, state) + + stopped = request_route_stop(tmp_path, now=lambda: 123.0) + + assert stopped.state == "stopped" + assert stopped.stop_requested is True + assert stopped.completed_at == 123.0 + assert load_route_execution(tmp_path).stop_requested is True + + +def test_stop_route_invokes_transport_stop_handler(tmp_path) -> None: + _save_authoring(tmp_path) + calls: list[str] = [] + state = DogOpsRouteExecutor(tmp_path).follow_route(dry_run=True) + state.state = "running" + save_route_execution(tmp_path, state) + + stopped = DogOpsRouteExecutor(tmp_path, stop_handler=lambda: calls.append("stop")).stop_route() + + assert stopped.state == "stopped" + assert calls == ["stop"] + events = RouteRunStore(tmp_path).route_run_events(stopped.route_run_id or "") + assert events[-1]["state"] == "stopped" + assert events[-1]["kind"] == "system" diff --git a/dimos/experimental/dogops/test_route_run_store.py b/dimos/experimental/dogops/test_route_run_store.py new file mode 100644 index 0000000000..6d50afa883 --- /dev/null +++ b/dimos/experimental/dogops/test_route_run_store.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import sqlite3 +import time + +from dimos.experimental.dogops.map_authoring import ( + EditableMapPoint, + EditableRoute, + EditableRouteWaypoint, + MapAuthoringState, + save_map_authoring, +) +from dimos.experimental.dogops.route_executor import DogOpsRouteExecutor +from dimos.experimental.dogops.route_actions import EditableRouteAction +from dimos.experimental.dogops.route_run_store import RouteRunStore, route_run_db_path + + +def _save_route(run_dir, route_id: str) -> None: + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id=route_id, + routes=[ + EditableRoute( + id=route_id, + label=route_id, + waypoints=[ + EditableRouteWaypoint( + id=f"{route_id}-WP-1", + label="Waypoint", + pose=EditableMapPoint(x=1.0, y=2.0), + ) + ], + ) + ], + ), + ) + + +def test_route_run_history_is_global_across_run_dirs(tmp_path) -> None: + first = tmp_path / ".dogops" / "runs" / "first" + second = tmp_path / ".dogops" / "runs" / "second" + _save_route(first, "ROUTE_A") + _save_route(second, "ROUTE_B") + + DogOpsRouteExecutor(first).follow_route(dry_run=True) + DogOpsRouteExecutor(second).follow_route(dry_run=True) + + assert route_run_db_path(first) == tmp_path / ".dogops" / "dogops.sqlite" + runs = RouteRunStore(first).list_route_runs() + assert {run["dogops_run_id"] for run in runs} == {"first", "second"} + assert {run["route_id"] for run in runs} == {"ROUTE_A", "ROUTE_B"} + + +def test_every_route_run_gets_distinct_history_record(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + _save_route(run_dir, "ROUTE_A") + + first = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + second = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + + assert first.route_run_id + assert second.route_run_id + assert first.route_run_id != second.route_run_id + runs = RouteRunStore(run_dir).list_route_runs() + assert [run["route_run_id"] for run in runs] == [second.route_run_id, first.route_run_id] + assert (run_dir / "route_runs" / first.route_run_id / "events.jsonl").exists() + assert (run_dir / "route_runs" / second.route_run_id / "route_run.json").exists() + + +def test_latest_image_evidence_for_waypoint_ignores_current_route_run(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + source_image = tmp_path / "frame.jpg" + source_image.write_bytes(b"fake-jpeg") + route = EditableRoute( + id="ROUTE_IMAGES", + label="Route Images", + waypoints=[ + EditableRouteWaypoint( + id="WP-IMAGE", + label="Image Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image), "target": "COOLING_1"}, + ) + ], + ) + ], + ) + save_map_authoring(run_dir, MapAuthoringState(selected_route_id="ROUTE_IMAGES", routes=[route])) + + first = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + second = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + + assert first.route_run_id + assert second.route_run_id + store = RouteRunStore(run_dir) + current = store.image_evidence_for_route_run_waypoint( + route_run_id=second.route_run_id, + waypoint_id="WP-IMAGE", + ) + baseline = store.latest_image_evidence_for_waypoint( + waypoint_id="WP-IMAGE", + exclude_route_run_id=second.route_run_id, + route_id="ROUTE_IMAGES", + target_id="COOLING_1", + ) + + assert current is not None + assert baseline is not None + assert current["route_run_id"] == second.route_run_id + assert baseline["route_run_id"] == first.route_run_id + assert baseline["baseline_match"] == "same_route_waypoint" + + +def test_latest_image_evidence_for_waypoint_ignores_future_route_runs(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + source_image = tmp_path / "frame.jpg" + source_image.write_bytes(b"fake-jpeg") + route = EditableRoute( + id="ROUTE_IMAGES", + label="Route Images", + waypoints=[ + EditableRouteWaypoint( + id="WP-IMAGE", + label="Image Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image), "target": "COOLING_1"}, + ) + ], + ) + ], + ) + save_map_authoring(run_dir, MapAuthoringState(selected_route_id="ROUTE_IMAGES", routes=[route])) + previous = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + current = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + future = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + assert previous.route_run_id + assert current.route_run_id + assert future.route_run_id + + now = time.time() + with sqlite3.connect(route_run_db_path(run_dir)) as conn: + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (now - 60.0, previous.route_run_id), + ) + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (now, current.route_run_id), + ) + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (now + 60.0, future.route_run_id), + ) + + baseline = RouteRunStore(run_dir).latest_image_evidence_for_waypoint( + waypoint_id="WP-IMAGE", + exclude_route_run_id=current.route_run_id, + route_id="ROUTE_IMAGES", + target_id="COOLING_1", + ) + + assert baseline is not None + assert baseline["route_run_id"] == previous.route_run_id + + +def test_latest_image_evidence_prefers_same_route_for_reused_waypoint_ids(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + source_image = tmp_path / "frame.jpg" + source_image.write_bytes(b"fake-jpeg") + + def route(route_id: str) -> EditableRoute: + return EditableRoute( + id=route_id, + label=route_id, + waypoints=[ + EditableRouteWaypoint( + id="WP-SHARED", + label="Shared Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image), "target": "COOLING_1"}, + ) + ], + ) + ], + ) + + save_map_authoring(run_dir, MapAuthoringState(selected_route_id="ROUTE_A", routes=[route("ROUTE_A")])) + route_a = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + save_map_authoring(run_dir, MapAuthoringState(selected_route_id="ROUTE_B", routes=[route("ROUTE_B")])) + route_b = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + current = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + assert route_a.route_run_id + assert route_b.route_run_id + assert current.route_run_id + + baseline = RouteRunStore(run_dir).latest_image_evidence_for_waypoint( + waypoint_id="WP-SHARED", + exclude_route_run_id=current.route_run_id, + route_id="ROUTE_B", + target_id="COOLING_1", + ) + + assert baseline is not None + assert baseline["route_run_id"] == route_b.route_run_id + assert baseline["baseline_match"] == "same_route_waypoint" + + +def test_yesterday_baseline_policy_prefers_previous_calendar_day(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + source_image = tmp_path / "frame.jpg" + source_image.write_bytes(b"fake-jpeg") + route = EditableRoute( + id="ROUTE_IMAGES", + label="Route Images", + waypoints=[ + EditableRouteWaypoint( + id="WP-IMAGE", + label="Image Waypoint", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"image_path": str(source_image), "target": "COOLING_1"}, + ) + ], + ) + ], + ) + save_map_authoring(run_dir, MapAuthoringState(selected_route_id="ROUTE_IMAGES", routes=[route])) + old = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + previous_day = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + current = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + assert old.route_run_id + assert previous_day.route_run_id + assert current.route_run_id + + current_started_at = time.time() + with sqlite3.connect(route_run_db_path(run_dir)) as conn: + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (current_started_at - 3 * 24 * 60 * 60, old.route_run_id), + ) + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (current_started_at - 24 * 60 * 60, previous_day.route_run_id), + ) + conn.execute( + "UPDATE route_runs SET started_at = ? WHERE route_run_id = ?", + (current_started_at, current.route_run_id), + ) + + baseline = RouteRunStore(run_dir).latest_image_evidence_for_waypoint( + waypoint_id="WP-IMAGE", + exclude_route_run_id=current.route_run_id, + route_id="ROUTE_IMAGES", + baseline_policy="yesterday", + ) + + assert baseline is not None + assert baseline["route_run_id"] == previous_day.route_run_id + assert baseline["baseline_match"] == "previous_day_same_route_waypoint" + + +def test_mission_steps_provide_default_route_actions_for_full_demo_route(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + from dimos.experimental.dogops.mission_engine import run_offline_simulation + + run_offline_simulation(out=run_dir) + save_map_authoring( + run_dir, + MapAuthoringState( + selected_route_id="ROUTE_DEFAULTS", + routes=[ + EditableRoute( + id="ROUTE_DEFAULTS", + label="Route Defaults", + waypoints=[ + EditableRouteWaypoint( + id="WP-INBOUND", + label="Inbound", + target_id="INBOUND_DOCK", + pose=EditableMapPoint(x=0.0, y=1.0), + ), + EditableRouteWaypoint( + id="WP-COOLING", + label="Cooling", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + EditableRouteWaypoint( + id="WP-QA", + label="QA Hold", + target_id="QA_HOLD", + pose=EditableMapPoint(x=2.0, y=3.0), + ) + ], + ) + ], + ), + ) + + state = DogOpsRouteExecutor(run_dir).follow_route(dry_run=True) + route_run = RouteRunStore(run_dir).route_run_detail(state.route_run_id or "") + + assert route_run is not None + assert route_run["actions_total"] >= 2 + actions_by_target = { + waypoint["target_id"]: [(action["id"], action["kind"], action["args"]) for action in waypoint["actions"]] + for waypoint in route_run["selected_route_snapshot"]["waypoints"] + } + assert [(action_id, kind) for action_id, kind, _ in actions_by_target["INBOUND_DOCK"]] == [ + ("scan_inbound_tags", "scan_tags"), + ("scan_inbound_qr", "scan_qr"), + ] + assert actions_by_target["INBOUND_DOCK"][0][2]["expected"] == [20, 101, 102] + assert actions_by_target["INBOUND_DOCK"][1][2]["expected"] == ["PKG-101", "PKG-102"] + assert [(action_id, kind) for action_id, kind, _ in actions_by_target["COOLING_1"]] == [ + ("inspect_cooling_image", "capture_image"), + ("inspect_cooling", "inspect_asset"), + ("wait_for_human_fix", "operator_prompt"), + ("verify_cooling_image", "capture_image"), + ("verify_cooling", "verify_work_order"), + ] + assert [(action_id, kind) for action_id, kind, _ in actions_by_target["QA_HOLD"]] == [ + ("scan_qa_hold_tags", "scan_tags"), + ("scan_qa_hold_qr", "scan_qr"), + ] + assert actions_by_target["QA_HOLD"][0][2]["expected"] == [30, 104] + assert actions_by_target["QA_HOLD"][1][2]["expected"] == ["PKG-104"] + + +def test_timeline_events_are_persisted_in_sqlite(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + store = RouteRunStore(run_dir) + + store.replace_timeline_events( + "latest", + [ + { + "event_id": "INC-001", + "ts": 1.0, + "kind": "incident", + "state": "open", + "target_id": "COOLING_1", + "note": "blocked cooling", + }, + { + "event_id": "WO-001", + "ts": 2.0, + "kind": "work_order", + "state": "verified_closed", + "target_id": "INC-001", + "note": "move package", + }, + ], + ) + + timeline = store.timeline_events(dogops_run_id="latest") + assert [event["kind"] for event in timeline] == ["incident", "work_order"] + assert timeline[0]["payload"] == {} + + +def test_timeline_event_ids_are_scoped_by_run_and_route_run(tmp_path) -> None: + run_dir = tmp_path / ".dogops" / "runs" / "latest" + store = RouteRunStore(run_dir) + + store.replace_timeline_events( + "first", + [ + { + "event_id": "INC-001", + "route_run_id": "RR-FIRST", + "ts": 1.0, + "kind": "incident", + "state": "open", + "note": "first incident", + }, + ], + route_run_id="RR-FIRST", + ) + store.replace_timeline_events( + "second", + [ + { + "event_id": "INC-001", + "route_run_id": "RR-SECOND", + "ts": 1.0, + "kind": "incident", + "state": "open", + "note": "second incident", + }, + ], + route_run_id="RR-SECOND", + ) + + first = store.timeline_events(dogops_run_id="first", route_run_id="RR-FIRST") + second = store.timeline_events(dogops_run_id="second", route_run_id="RR-SECOND") + assert [event["note"] for event in first] == ["first incident"] + assert [event["note"] for event in second] == ["second incident"] diff --git a/dimos/experimental/dogops/test_skills.py b/dimos/experimental/dogops/test_skills.py new file mode 100644 index 0000000000..cf1884dff8 --- /dev/null +++ b/dimos/experimental/dogops/test_skills.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import inspect +import json +import time +from typing import Any + +import pytest + +from dimos.experimental.dogops.map_authoring import ( + EditableMapPoint, + EditableRoute, + EditableRouteWaypoint, + MapAuthoringState, + save_map_authoring, +) +from dimos.experimental.dogops.route_actions import EditableRouteAction +from dimos.experimental.dogops.route_executor import load_route_execution, save_route_execution +from dimos.experimental.dogops.skills import DogOpsSkillContainer + + +def _payload(raw: str) -> dict[str, object]: + return json.loads(raw) + + +class _ImageLike: + def __init__(self, image, *, frame_id: str = "camera_optical", encoding: str = "bgr8") -> None: + self._image = image + self.frame_id = frame_id + self.encoding = encoding + + def to_opencv(self): + return self._image + + +class _ArrayLike: + def __init__(self, rows: list[list[list[int]]]) -> None: + self._rows = rows + self.shape = (len(rows), len(rows[0]), len(rows[0][0])) + + def __getitem__(self, index: int) -> list[list[int]]: + return self._rows[index] + + +class _FakePointPublisher: + def __init__(self) -> None: + self.points: list[Any] = [] + + def publish(self, point: Any) -> None: + self.points.append(point) + + +class _FakeHeatmapAdapter: + def snapshot(self) -> dict[str, object]: + return { + "ok": True, + "source": "DimOS live LCM topics", + "status": "receiving", + "topics": {"navigation_costmap": {"received": True}}, + "costmap": { + "source": "DimOS live costmap", + "columns": 1, + "rows": 1, + "cells": [{"x": 0.0, "y": 0.0, "width": 0.5, "height": 0.5, "cost": 0.25}], + }, + "path": [], + "route": [], + "robot_pose": None, + "target": None, + } + + +def test_skill_container_runs_closed_loop_and_reports_state(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + assert _payload(skills.load_site_config())["packages"] == 4 + assert _payload(skills.load_manifest())["packages"] == 4 + assert _payload(skills.load_mission())["mission_id"] == "receiving_sre_demo" + + run = _payload(skills.run_mission()) + assert run["ok"] is True + assert run["state"] == "done" + + scan = _payload(skills.scan_zone("INBOUND_DOCK")) + assert scan["visible_tag_ids"] == [20, 101, 102] + + manifest_scan = _payload(skills.scan_receiving_manifest("INBOUND_DOCK")) + assert manifest_scan["expected_package_ids"] == ["PKG-101", "PKG-102", "PKG-103"] + assert manifest_scan["observed_package_ids"] == ["PKG-101", "PKG-102"] + assert manifest_scan["missing_package_ids"] == ["PKG-103"] + assert manifest_scan["manifest_exceptions"] == 1 + + asset = _payload(skills.inspect_asset("COOLING_1")) + assert asset["ok"] is True + assert asset["expected_clear"] is True + + clearance = _payload(skills.check_clearance("COOLING_1")) + assert clearance["clearance_clear"] is True + assert clearance["evidence_observation_id"] == "OBS-004" + + gauge = _payload(skills.read_gauge("TEMP_1")) + assert gauge["within_threshold"] is True + assert gauge["reading_celsius"] == 28.0 + + aisle = _payload(skills.detect_blocked_aisle("AISLE_1")) + assert aisle["blocked"] is False + assert aisle["clearance_clear"] is True + + reconciliation = _payload(skills.reconcile_manifest()) + assert reconciliation["manifest_exceptions"] == 2 + + changes = _payload(skills.what_changed()) + assert "PKG-104 moved" in str(changes["changes"]) + + nav = _payload(skills.nav_eval_report()) + assert nav["nav_summary"]["waypoints_reached"] == 4 # type: ignore[index] + + +def test_skill_container_gather_heatmap_records_costmap_run(tmp_path) -> None: + skills = DogOpsSkillContainer( + run_dir=tmp_path / ".dogops" / "runs" / "latest", + live_map_adapter=_FakeHeatmapAdapter(), # type: ignore[arg-type] + ) + + result = _payload(skills.gather_heatmap(area_id="AISLE_1", duration_s=0.0)) + + assert result["ok"] is True + assert result["skill"] == "gather_heatmap" + assert result["run_kind"] == "gather_heatmap" + assert result["heatmap"]["area_id"] == "AISLE_1" # type: ignore[index] + assert (tmp_path / ".dogops" / "runs" / "latest" / "heatmaps" / "latest_heatmap.json").is_file() + + +def test_skill_container_go_to_publishes_clicked_point(tmp_path) -> None: + publisher = _FakePointPublisher() + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + skills.clicked_point = publisher # type: ignore[attr-defined] + + result = _payload(skills.go_to(1.25, -0.5)) + + assert result["ok"] is True + assert result["skill"] == "go_to" + assert result["transport"] == "clicked_point" + assert result["x"] == 1.25 + assert result["y"] == -0.5 + assert result["z"] == 0.0 + assert result["frame_id"] == "map" + assert len(publisher.points) == 1 + point = publisher.points[0] + assert point.x == 1.25 + assert point.y == -0.5 + assert point.z == 0.0 + assert point.frame_id == "map" + + +def test_skill_container_go_to_rejects_invalid_target(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.go_to(float("nan"), 0.0)) + + assert result["ok"] is False + assert result["skill"] == "go_to" + assert result["error"] == "invalid_go_to_target" + + +def test_skill_container_go_to_reports_missing_navigation_stream(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.go_to(1.0, 2.0)) + + assert result["ok"] is False + assert result["skill"] == "go_to" + assert result["error"] == "navigation_stream_unavailable" + + +def test_skill_container_reports_missing_camera_frame(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.camera_stream_status()) + + assert result["ok"] is False + assert result["mode"] == "not_subscribed" + + +def test_skill_container_scan_zone_uses_latest_camera_frame(tmp_path) -> None: + cv2 = pytest.importorskip("cv2") + np = pytest.importorskip("numpy") + if not hasattr(cv2, "aruco"): + pytest.skip("OpenCV aruco is unavailable") + aruco = cv2.aruco + dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11) + if hasattr(aruco, "generateImageMarker"): + marker = aruco.generateImageMarker(dictionary, 104, 240) + else: + marker = aruco.drawMarker(dictionary, 104, 240) + canvas = np.full((320, 320), 255, dtype=marker.dtype) + canvas[40:280, 40:280] = marker + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + skills.ingest_camera_image(_ImageLike(canvas)) + + status = _payload(skills.camera_stream_status()) + result = _payload(skills.scan_zone("QA_HOLD")) + + assert status["ok"] is True + assert result["ok"] is True + assert result["source"] == "camera" + assert result["visible_tag_ids"] == [104] + assert result["package_ids"] == ["PKG-104"] + assert result["evidence_observation_ids"] == [] + + +def test_skill_container_capture_image_uses_latest_camera_frame(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_CAMERA_CAPTURE", + routes=[ + EditableRoute( + id="ROUTE_CAMERA_CAPTURE", + label="Route Camera Capture", + waypoints=[ + EditableRouteWaypoint( + id="WP-CAMERA", + label="Camera", + target_id="COOLING_1", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"target": "COOLING_1"}, + ) + ], + ), + ], + ) + ], + ), + ) + frame = _ArrayLike( + [ + [[0, 0, 255], [0, 255, 0]], + [[255, 0, 0], [255, 255, 255]], + ] + ) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + skills.ingest_camera_image(_ImageLike(frame, frame_id="front-camera")) + + result = _payload(skills.follow_route(dry_run=True)) + + assert result["ok"] is True + events = result["route_execution"]["events"] # type: ignore[index] + completed = [event for event in events if event.get("action_id") == "CAPTURE"][-1] + assert completed["payload"]["source"] == "go2_camera_live" + evidence = completed["payload"]["evidence"][0] + assert evidence["metadata"]["source"] == "go2_camera_live" + assert evidence["metadata"]["camera_frame_id"] == "front-camera" + image_path = tmp_path / "latest" / "route_runs" / result["route_execution"]["route_run_id"] / "evidence" / "WP-CAMERA-CAPTURE.png" # type: ignore[index] + assert image_path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +def test_skill_container_capture_image_fails_without_live_camera_frame(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_CAMERA_CAPTURE_MISSING", + routes=[ + EditableRoute( + id="ROUTE_CAMERA_CAPTURE_MISSING", + label="Route Camera Capture Missing", + waypoints=[ + EditableRouteWaypoint( + id="WP-CAMERA", + label="Camera", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + ) + ], + ), + ], + ) + ], + ), + ) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.follow_route(dry_run=True)) + + assert result["ok"] is False + assert result["state"] == "failed" + assert "no live Go2 camera frame is available" in str(result["last_error"]) + + +def test_skill_container_capture_image_rejects_stale_camera_frame(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_CAMERA_CAPTURE_STALE", + routes=[ + EditableRoute( + id="ROUTE_CAMERA_CAPTURE_STALE", + label="Route Camera Capture Stale", + waypoints=[ + EditableRouteWaypoint( + id="WP-CAMERA", + label="Camera", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="CAPTURE", + kind="capture_image", + args={"max_camera_frame_age_s": 0.5}, + ) + ], + ), + ], + ) + ], + ), + ) + frame = _ArrayLike([[[0, 0, 255]]]) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + skills.ingest_camera_image(_ImageLike(frame, frame_id="front-camera")) + skills._latest_camera_received_at = time.time() - 2.0 + + result = _payload(skills.follow_route(dry_run=True)) + + assert result["ok"] is False + assert result["state"] == "failed" + assert "latest Go2 camera frame is stale" in str(result["last_error"]) + + +def test_skill_container_route_skills_validate_and_report_dry_run(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="One", + target_id="CHECKPOINT_1", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + ], + ) + ], + ), + ) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.follow_route(dry_run=True)) + status = _payload(skills.route_status()) + + assert result["ok"] is True + assert result["skill"] == "follow_route" + assert result["route_id"] == "ROUTE_A" + assert result["state"] == "completed" + assert result["transport"] == "dry_run" + assert status["state"] == "completed" + + +def test_skill_container_qr_route_action_uses_scan_zone_flow(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_SCAN_QR", + routes=[ + EditableRoute( + id="ROUTE_SCAN_QR", + label="Route Scan QR", + waypoints=[ + EditableRouteWaypoint( + id="WP-INBOUND", + label="Inbound", + target_id="INBOUND_DOCK", + pose=EditableMapPoint(x=1.0, y=2.0), + actions=[ + EditableRouteAction( + id="SCAN-QR", + kind="scan_qr", + args={}, + ) + ], + ), + ], + ) + ], + ), + ) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.follow_route(dry_run=True)) + + assert result["ok"] is True + events = result["route_execution"]["events"] # type: ignore[index] + completed = [event for event in events if event.get("action_id") == "SCAN-QR"][-1] + assert completed["state"] == "completed" + assert completed["payload"]["source"] == "scan_zone" + assert completed["payload"]["scan_zone_source"] == "simulation" + assert completed["payload"]["detected_payloads"] == ["PKG-101", "PKG-102"] + + +def test_skill_container_follow_route_requires_navigation_stream_for_live_run(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="One", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + ], + ) + ], + ), + ) + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + result = _payload(skills.follow_route(dry_run=False)) + + assert result["ok"] is False + assert result["skill"] == "follow_route" + assert result["error"] == "navigation_stream_unavailable" + + +def test_skill_container_stop_route_marks_execution_stopped(tmp_path) -> None: + save_map_authoring( + tmp_path / "latest", + MapAuthoringState( + selected_route_id="ROUTE_A", + routes=[ + EditableRoute( + id="ROUTE_A", + label="Route A", + waypoints=[ + EditableRouteWaypoint( + id="WP-1", + label="One", + pose=EditableMapPoint(x=1.0, y=2.0), + ), + ], + ) + ], + ), + ) + stop_calls: list[str] = [] + skills = DogOpsSkillContainer( + run_dir=tmp_path / "latest", + route_stop_handler=lambda: stop_calls.append("stop"), + ) + _payload(skills.follow_route(dry_run=True)) + state = load_route_execution(tmp_path / "latest") + state.state = "running" + save_route_execution(tmp_path / "latest", state) + + result = _payload(skills.stop_route()) + + assert result["ok"] is True + assert result["state"] == "stopped" + assert result["route_execution"]["stop_requested"] is True # type: ignore[index] + assert stop_calls == ["stop"] + + +def test_skill_container_work_order_methods_are_idempotent(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + skills.run_mission() + + existing = _payload(skills.open_work_order("COOLING_1", "blocked_cooling")) + assert existing["incident_id"] == "INC-001" + assert existing["work_order_id"] == "WO-001" + + ready = _payload(skills.mark_ready_to_verify("WO-001")) + assert ready["ok"] is True + + verified = _payload(skills.verify_work_order("WO-001")) + assert verified["state"] == "verified_closed" + + +def test_skill_container_stretch_skills_are_simulated_without_cloud_keys(tmp_path) -> None: + skills = DogOpsSkillContainer(run_dir=tmp_path / "latest") + + dock = _payload(skills.dock_align()) + assert dock["ok"] is True + assert dock["simulated"] is True + + portal = _payload(skills.portal_entry()) + assert portal["ok"] is True + assert portal["door_open"] is True + + stopped = _payload(skills.stop_mission()) + assert stopped["state"] == "not_started" + + +def test_skill_container_mcp_skills_have_docstrings() -> None: + skill_methods = [ + method + for _, method in inspect.getmembers(DogOpsSkillContainer, predicate=callable) + if getattr(method, "__dogops_skill__", False) or getattr(method, "__skill__", False) + ] + + assert skill_methods + assert all(inspect.getdoc(method) for method in skill_methods) diff --git a/dimos/experimental/dogops/test_store.py b/dimos/experimental/dogops/test_store.py new file mode 100644 index 0000000000..5d614c1621 --- /dev/null +++ b/dimos/experimental/dogops/test_store.py @@ -0,0 +1,21 @@ +from dimos.experimental.dogops.mission_engine import run_offline_simulation +from dimos.experimental.dogops.store import DogOpsStore + + +def test_store_writes_required_run_files(tmp_path) -> None: + run_dir = tmp_path / "latest" + run_offline_simulation(out=run_dir) + + assert (run_dir / "run.json").is_file() + assert (run_dir / "observations.jsonl").is_file() + assert (run_dir / "incidents.jsonl").is_file() + assert (run_dir / "work_orders.jsonl").is_file() + assert (run_dir / "nav_events.jsonl").is_file() + assert (run_dir / "state.json").is_file() + assert (run_dir / "report.json").is_file() + assert (run_dir / "report.md").is_file() + assert (run_dir / "evidence").is_dir() + + state = DogOpsStore.load_existing(run_dir).load_state() + assert state.run.id == "latest" + assert len(state.incidents) == 2 diff --git a/dimos/experimental/dogops/testdata/factory-test-image-1.png b/dimos/experimental/dogops/testdata/factory-test-image-1.png new file mode 100644 index 0000000000..055d160a05 --- /dev/null +++ b/dimos/experimental/dogops/testdata/factory-test-image-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbc0adefab3c54b238c9749890adc410643b4b7f1722bea0d2436d5e56e19e0f +size 2065527 diff --git a/dimos/experimental/dogops/testdata/factory-test-image-2.png b/dimos/experimental/dogops/testdata/factory-test-image-2.png new file mode 100644 index 0000000000..6b4a8a9884 --- /dev/null +++ b/dimos/experimental/dogops/testdata/factory-test-image-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d220fafb1b3edc58d2de4cd572624b1509736d212cb4dc24e2319823c78f41c6 +size 2233477 diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 3d101cca79..fc262234d2 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -55,6 +55,7 @@ "demo-osm": "dimos.mapping.osm.demo_osm:demo_osm", "demo-skill": "dimos.agents.skills.demo_skill:demo_skill", "desk-marker-tf": "dimos.perception.fiducial.blueprints.desk_marker_tf:desk_marker_tf", + "dogops-go2-stack": "dimos.experimental.dogops.blueprints:dogops_go2_stack", "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.manipulation.blueprints:dual_xarm6_planner", @@ -99,6 +100,7 @@ "unitree-go2-basic": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_basic:unitree_go2_basic", "unitree-go2-coordinator": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_coordinator:unitree_go2_coordinator", "unitree-go2-detection": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_detection:unitree_go2_detection", + "unitree-go2-dogops": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_dogops:unitree_go2_dogops", "unitree-go2-fleet": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_fleet:unitree_go2_fleet", "unitree-go2-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_keyboard_teleop:unitree_go2_keyboard_teleop", "unitree-go2-markers": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_markers", @@ -136,6 +138,8 @@ "desk-static-tf-module": "dimos.perception.fiducial.blueprints.desk_marker_tf.DeskStaticTfModule", "detection2-d-module": "dimos.perception.detection.module2D.Detection2DModule", "detection3-d-module": "dimos.perception.detection.module3D.Detection3DModule", + "dog-ops-dashboard-module": "dimos.experimental.dogops.dashboard.DogOpsDashboardModule", + "dog-ops-skill-container": "dimos.experimental.dogops.skills.DogOpsSkillContainer", "drone-camera-module": "dimos.robot.drone.camera_module.DroneCameraModule", "drone-connection-module": "dimos.robot.drone.connection_module.DroneConnectionModule", "drone-tracking-module": "dimos.robot.drone.drone_tracking_module.DroneTrackingModule", diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_dogops.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_dogops.py new file mode 100644 index 0000000000..f4fe07f627 --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_dogops.py @@ -0,0 +1,45 @@ +try: # pragma: no cover - exercised inside a full DimOS checkout. + from dimos.agents.mcp.mcp_server import McpServer + from dimos.core.coordination.blueprints import autoconnect + from dimos.experimental.dogops.dashboard import DogOpsDashboardModule + from dimos.experimental.dogops.nav_eval import DogOpsNavEvalModule + from dimos.experimental.dogops.observation_module import DogOpsObservationModule + from dimos.experimental.dogops.skills import DogOpsSkillContainer + from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2_markers +except ModuleNotFoundError: # pragma: no cover - covered by local pack fallback tests. + class _FallbackBlueprint: + def __init__(self, *modules: object) -> None: + self.modules = modules + + def global_config(self, **_: object) -> "_FallbackBlueprint": + return self + + def __repr__(self) -> str: + return "unitree-go2-dogops " + " ".join(str(module) for module in self.modules) + + class _FallbackModule: + @classmethod + def blueprint(cls) -> str: + return cls.__name__ + + def autoconnect(*modules: object) -> _FallbackBlueprint: + return _FallbackBlueprint(*modules) + + unitree_go2_markers = "unitree_go2_markers" + DogOpsObservationModule = type("DogOpsObservationModule", (_FallbackModule,), {}) + DogOpsSkillContainer = type("DogOpsSkillContainer", (_FallbackModule,), {}) + DogOpsDashboardModule = type("DogOpsDashboardModule", (_FallbackModule,), {}) + DogOpsNavEvalModule = type("DogOpsNavEvalModule", (_FallbackModule,), {}) + McpServer = type("McpServer", (_FallbackModule,), {}) + + +unitree_go2_dogops = autoconnect( + unitree_go2_markers, + DogOpsObservationModule.blueprint(), + DogOpsSkillContainer.blueprint(runtime_module=True), + DogOpsDashboardModule.blueprint(), + DogOpsNavEvalModule.blueprint(), + McpServer.blueprint(), +).global_config(n_workers=12, robot_model="unitree_go2") + +__all__ = ["unitree_go2_dogops"] diff --git a/docs/dogops/README.md b/docs/dogops/README.md new file mode 100644 index 0000000000..172f82fd95 --- /dev/null +++ b/docs/dogops/README.md @@ -0,0 +1,83 @@ +# DogOps SiteOps Agent + +DogOps is an experimental Unitree Go2 SiteOps dashboard and MCP skill package. +It lives inside DimOS so the demo can run from one checkout without the +separate `dimos-unitree-go2` planning repo. + +## Offline Smoke + +```bash +uv run --no-sync python -m dimos.experimental.dogops.cli validate +uv run --no-sync pytest -q dimos/experimental/dogops +uv run --no-sync python -m dimos.experimental.dogops.cli simulate --out .dogops/runs/latest +uv run --no-sync python -m dimos.experimental.dogops.cli serve --run .dogops/runs/latest --host 127.0.0.1 --port 18768 +``` + +Open `http://127.0.0.1:18768/`. + +## Go2 Runtime + +Use the real robot only after the base Go2 route and multicast route are known +good. Keep the stop command ready: + +```bash +uv run --no-sync dimos stop --force +``` + +On macOS, DimOS LCM needs multicast routed to loopback: + +```bash +sudo route delete -net 224.0.0.0/4 || true +sudo route add -net 224.0.0.0/4 -interface lo0 +route -n get 224.0.0.1 +``` + +`route -n get 224.0.0.1` must report `interface: lo0`. If it still reports a +VPN/tunnel interface such as `utun7`, disconnect that tunnel or remove its +multicast route before starting DimOS. + +Start the DogOps Go2 stack: + +```bash +GO2_IP=192.168.12.1 \ +DOGOPS_ROBOT_IP=192.168.12.1 \ +DOGOPS_SKIP_GO2_STARTUP_POSTURE=1 \ +NO_PROXY=127.0.0.1,localhost \ +no_proxy=127.0.0.1,localhost \ +uv run --no-sync dimos --viewer none --rerun-open none --no-rerun-web run unitree-go2-dogops \ + -o go2connection.ip=192.168.12.1 +``` + +In another shell: + +```bash +NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost \ +uv run --no-sync python -m dimos.experimental.dogops.cli serve --run .dogops/runs/latest --host 127.0.0.1 --port 18768 +``` + +## Safe Route Workflow + +Start with plain movement-only routes: + +1. Confirm the dashboard shows live odom and costmap. +2. Create one waypoint within about `0.3m` to `0.5m` of the robot. +3. Run `Dry Run Route`. +4. Run `Run Live Route`. + +Do not add `capture_image`, `gemini_inspect_image`, or `Gather Heatmap` to the +first hardware smoke. Camera/image actions are best-effort evidence steps and +should not be used as the first movement validation. + +## Preflight Script + +```bash +GO2_IP=192.168.12.1 scripts/dogops_go2_preflight.sh +``` + +By default this runs offline validation, registry checks, simulation, and an +optional robot ping. Hardware smoke is opt-in: + +```bash +GO2_IP=192.168.12.1 RUN_GO2_SMOKE=1 scripts/dogops_go2_preflight.sh +GO2_IP=192.168.12.1 RUN_DOGOPS_SMOKE=1 scripts/dogops_go2_preflight.sh +``` diff --git a/examples/dogops/manifest_demo.yaml b/examples/dogops/manifest_demo.yaml new file mode 100644 index 0000000000..d1f66b2502 --- /dev/null +++ b/examples/dogops/manifest_demo.yaml @@ -0,0 +1,14 @@ +manifest_id: inbound_manifest_demo +items: + - package_id: PKG-101 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - package_id: PKG-102 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - package_id: PKG-103 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - package_id: PKG-104 + expected_zone_id: QA_HOLD + expected_condition: ok diff --git a/examples/dogops/mission_demo.yaml b/examples/dogops/mission_demo.yaml new file mode 100644 index 0000000000..80e986c3dc --- /dev/null +++ b/examples/dogops/mission_demo.yaml @@ -0,0 +1,117 @@ +mission_id: receiving_sre_demo +display_name: Receiving + Physical SRE Demo +verify_after_human: true +hardware_required_for_final: true +fallback_levels: + L0: full autonomous Go2 + dashboard + MCP + L1: Go2 scans tags + guided navigation + dashboard + L2: Go2 movement/tag video + offline dashboard/report + L3: offline product demo + recorded robot clip only +operator_prompts: + before_start: Confirm stop command, clear route, mounted tags, and GO2_IP. + human_fix: Move PKG-104 from COOLING_1/RACK_ROW_A to QA_HOLD, then mark ready to verify. + after_run: Save report.md, report.json, terminal logs, dashboard capture, robot clip, and fallback level. +steps: + - id: localize_home + action: localize + target_id: HOME + timeout_s: 20 + required: true + - id: scan_inbound + action: scan_zone + target_id: INBOUND_DOCK + timeout_s: 30 + required: true + - id: reconcile_manifest + action: reconcile_manifest + target_id: INBOUND_DOCK + timeout_s: 10 + required: true + - id: inspect_cooling + action: inspect_asset + target_id: COOLING_1 + timeout_s: 30 + required: true + - id: open_work_orders + action: open_work_orders + target_id: RACK_ROW_A + timeout_s: 10 + required: true + - id: wait_for_human_fix + action: wait_for_human_fix + target_id: COOLING_1 + timeout_s: 60 + required: true + - id: verify_cooling + action: verify_work_order + target_id: COOLING_1 + timeout_s: 30 + required: true + - id: scan_qa_hold + action: scan_zone + target_id: QA_HOLD + timeout_s: 30 + required: false + - id: report + action: report + target_id: HOME + timeout_s: 10 + required: true + +simulation_observations: + scan_inbound: + zone_id: INBOUND_DOCK + visible_tag_ids: [20, 101, 102] + facts: + PKG-101.zone_id: INBOUND_DOCK + PKG-102.zone_id: INBOUND_DOCK + inspect_cooling: + zone_id: RACK_ROW_A + visible_tag_ids: [40, 41, 104] + facts: + COOLING_1.clearance_clear: false + PKG-104.zone_id: RACK_ROW_A + PKG-104.blocks_asset_id: COOLING_1 + verify_cooling_before_fix: + zone_id: RACK_ROW_A + visible_tag_ids: [40, 41, 104] + facts: + COOLING_1.clearance_clear: false + verify_cooling_after_fix: + zone_id: RACK_ROW_A + visible_tag_ids: [40, 41] + facts: + COOLING_1.clearance_clear: true + PKG-104.zone_id: QA_HOLD + scan_qa_hold: + zone_id: QA_HOLD + visible_tag_ids: [30, 104] + facts: + PKG-104.zone_id: QA_HOLD + +nav_simulation: + guided: false + hardware_target: unitree-go2-dogops + expected_hardware_mode: L0_or_L1 + events: + - target_id: HOME + action: goto + success: true + elapsed_s: 4.0 + retries: 0 + - target_id: INBOUND_DOCK + action: goto + success: true + elapsed_s: 8.0 + retries: 0 + - target_id: COOLING_1 + action: goto + success: true + elapsed_s: 10.0 + retries: 1 + note: tag search recovery used + - target_id: QA_HOLD + action: goto + success: true + elapsed_s: 7.0 + retries: 0 diff --git a/examples/dogops/policy_demo.yaml b/examples/dogops/policy_demo.yaml new file mode 100644 index 0000000000..17c25530ee --- /dev/null +++ b/examples/dogops/policy_demo.yaml @@ -0,0 +1,30 @@ +policy_id: dogops_demo_policy +safety: + robot_may_push_objects: false + human_remediation_required: true + emergency_stop_command: uv run dimos stop --force + guided_mode_allowed_if_recorded: true +rules: + - id: cooling_clearance_no_packages + severity: P1 + incident_type: blocked_cooling + description: Packages must not block COOLING_1. + condition: + asset_id: COOLING_1 + forbidden_package_ids: [PKG-104] + recommended_action: Move blocking package to QA_HOLD and verify COOLING_1 is clear. + - id: manifest_missing_package + severity: P2 + incident_type: missing_package + description: Packages listed in the manifest must be found or marked missing. + recommended_action: Search inbound dock and QA_HOLD; escalate if not found. + - id: wrong_zone_package + severity: P2 + incident_type: wrong_zone + description: Packages must be in expected zone unless assigned to correction zone. + recommended_action: Move package to expected zone. + - id: no_go_zone + severity: P1 + incident_type: no_go_breach + description: Robot must not enter a no-go maintenance zone. + recommended_action: Stop and reroute around NO_GO_1. diff --git a/examples/dogops/qr_cargo_event_sample.json b/examples/dogops/qr_cargo_event_sample.json new file mode 100644 index 0000000000..f912cca756 --- /dev/null +++ b/examples/dogops/qr_cargo_event_sample.json @@ -0,0 +1,29 @@ +{ + "timestamp": 1779875037.519, + "source": "image_file", + "status": "decoded", + "qr_payload_raw": "{\"v\":1,\"type\":\"cargo\",\"warehouse_id\":\"WH-03\",\"location_node_id\":\"WH03-A12-SHELF05\",\"zone\":\"A12\",\"shelf_id\":\"SHELF-05\",\"cargo_id\":\"BOX-20260527-018\",\"task\":\"scan_and_report\"}", + "qr_payload": { + "v": 1, + "type": "cargo", + "warehouse_id": "WH-03", + "location_node_id": "WH03-A12-SHELF05", + "zone": "A12", + "shelf_id": "SHELF-05", + "cargo_id": "BOX-20260527-018", + "task": "scan_and_report" + }, + "robot_pose_at_detection": { + "frame": "map", + "x": null, + "y": null, + "yaw": null + }, + "bbox_px": [ + [40.0, 40.0], + [289.0, 40.0], + [289.0, 289.0], + [40.0, 289.0] + ], + "action_policy": "report_only" +} diff --git a/examples/dogops/site_demo.yaml b/examples/dogops/site_demo.yaml new file mode 100644 index 0000000000..0f8abc5f30 --- /dev/null +++ b/examples/dogops/site_demo.yaml @@ -0,0 +1,132 @@ +site_id: dogops_demo_site +site_name: DogOps Demo Facility +marker_length_m: 0.14 +tag_family: tag36h11 +hardware_profile: + robot: unitree_go2_air + host: mac_offboard_dimos + real_robot_available: true + route_style: short_guided_safe + max_demo_speed: low + operator_stop_command: uv run dimos stop --force + notes: + - Use the real Go2 for final validation when network/base unitree-go2 smoke is healthy. + - Keep UTM/Ubuntu optional; hardware runs happen from the Mac/full DimOS checkout. + - Human moves PKG-104; the robot observes and verifies only. + +zones: + - id: HOME + kind: zone + zone_kind: home + tag_id: 10 + display_name: Home / NOC + radius_m: 0.8 + pose_hint: {x: 0.0, y: 0.0, theta_deg: 0.0, frame: world, source: demo_layout_short_route} + - id: INBOUND_DOCK + kind: zone + zone_kind: inbound_dock + tag_id: 20 + display_name: Inbound Dock + radius_m: 1.0 + pose_hint: {x: 1.5, y: 0.0, theta_deg: 0.0, frame: world, source: demo_layout_short_route} + - id: QA_HOLD + kind: zone + zone_kind: qa_hold + tag_id: 30 + display_name: QA Hold + radius_m: 1.0 + pose_hint: {x: 1.5, y: -1.5, theta_deg: 0.0, frame: world, source: demo_layout_short_route} + - id: RACK_ROW_A + kind: zone + zone_kind: rack_row + tag_id: 40 + display_name: Rack Row A + radius_m: 1.2 + pose_hint: {x: 3.0, y: 0.0, theta_deg: 0.0, frame: world, source: demo_layout_short_route} + - id: NO_GO_1 + kind: zone + zone_kind: no_go + tag_id: 50 + display_name: Maintenance No-Go Zone + radius_m: 1.0 + no_go: true + pose_hint: {x: 3.0, y: -1.5, theta_deg: 0.0, frame: world, source: demo_layout_short_route} + +assets: + - id: COOLING_1 + kind: asset + asset_kind: cooling_clearance + tag_id: 41 + display_name: Cooling Clearance 1 + zone_id: RACK_ROW_A + severity_if_failed: P1 + expected_clear: true + expected_status: clear + expected_state: + clearance_clear: true + blocking_package_ids: [] + notes: Critical airflow path must remain clear. + - id: AISLE_1 + kind: asset + asset_kind: aisle_clearance + tag_id: 42 + display_name: Main Aisle 1 + zone_id: RACK_ROW_A + severity_if_failed: P2 + expected_clear: true + expected_status: clear + expected_state: + clearance_clear: true + notes: Aisle must remain passable. + - id: TEMP_1 + kind: asset + asset_kind: temperature_station + tag_id: 43 + display_name: Temperature Station 1 + zone_id: RACK_ROW_A + severity_if_failed: P2 + expected_clear: null + expected_status: below_threshold + expected_state: + max_celsius: 30.0 + notes: Optional manual thermometer input. + +packages: + - id: PKG-101 + kind: package + tag_id: 101 + display_name: Package 101 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - id: PKG-102 + kind: package + tag_id: 102 + display_name: Package 102 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - id: PKG-103 + kind: package + tag_id: 103 + display_name: Package 103 + expected_zone_id: INBOUND_DOCK + expected_condition: ok + - id: PKG-104 + kind: package + tag_id: 104 + display_name: Package 104 + expected_zone_id: QA_HOLD + expected_condition: ok + +special_entities: + dock: + id: DOCK_1 + kind: dock + tag_id: 60 + display_name: Dock Alignment Target + zone_id: HOME + portal: + id: PORTAL_1 + kind: portal + tag_id: 70 + display_name: Elevator / Portal Simulation + zone_id: RACK_ROW_A diff --git a/scripts/dogops_go2_preflight.sh b/scripts/dogops_go2_preflight.sh new file mode 100755 index 0000000000..4e30933c59 --- /dev/null +++ b/scripts/dogops_go2_preflight.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +export UV_CACHE_DIR="${UV_CACHE_DIR:-${TMPDIR:-/tmp}/dogops-uv-cache}" +export NO_PROXY="${NO_PROXY:-127.0.0.1,localhost}" +export no_proxy="${no_proxy:-127.0.0.1,localhost}" + +RUN_DIR="${RUN_DIR:-.dogops/runs/latest}" +LOG_DIR="${LOG_DIR:-.dogops/preflight/$(date +%Y%m%d-%H%M%S)}" +ROBOT_STARTED=0 +DOGOPS_REQUIRED_TOOLS=( + run_mission + go_to + follow_route + stop_route + route_status + scan_zone + read_gauge + check_clearance + detect_blocked_aisle + scan_receiving_manifest + verify_work_order + nav_eval_report +) +if [[ -n "${UV_RUN_ARGS:-}" ]]; then + read -r -a uv_run_args <<< "${UV_RUN_ARGS}" +else + uv_run_args=(--no-sync) +fi +mkdir -p "${LOG_DIR}" + +log() { + printf '%s\n' "$*" | tee -a "${LOG_DIR}/preflight.log" +} + +run() { + log "+ $*" + "$@" 2>&1 | tee -a "${LOG_DIR}/preflight.log" +} + +run_optional() { + log "+ $*" + if ! "$@" 2>&1 | tee -a "${LOG_DIR}/preflight.log"; then + log "WARN: optional command failed: $*" + fi +} + +uv_run() { + uv run "${uv_run_args[@]}" "$@" +} + +require_match() { + local pattern="$1" + local path="$2" + local label="$3" + if ! rg "${pattern}" "${path}" >/dev/null; then + log "FAILED: ${label} did not match ${pattern}" + log "Saved evidence in ${LOG_DIR}" + return 1 + fi + log "OK: ${label}" +} + +cleanup() { + if [[ "${ROBOT_STARTED}" == "1" ]]; then + log "cleanup: stopping DimOS robot runtime" + uv_run dimos stop --force 2>&1 | tee -a "${LOG_DIR}/preflight.log" || true + fi +} + +trap cleanup EXIT + +log "DogOps Go2 preflight" +log "cwd=$(pwd)" +log "run_dir=${RUN_DIR}" +log "log_dir=${LOG_DIR}" +log "uv_run_args=${uv_run_args[*]}" +log "stop_command=uv run ${uv_run_args[*]} dimos stop --force" + +run uv_run python --version +run uv_run python -m dimos.experimental.dogops.cli validate +run uv_run pytest -q dimos/experimental/dogops +run uv_run python -m dimos.experimental.dogops.cli simulate --out "${RUN_DIR}" + +log "+ uv_run dimos list" +uv_run dimos list >"${LOG_DIR}/dimos-list.txt" 2>"${LOG_DIR}/dimos-list.err" +require_match "unitree-go2" "${LOG_DIR}/dimos-list.txt" "base Go2 registry" +require_match "dogops" "${LOG_DIR}/dimos-list.txt" "DogOps registry" + +if [[ -n "${GO2_IP:-}" ]]; then + run ping -c 3 "${GO2_IP}" +else + log "GO2_IP is not set; hardware ping and smoke are skipped." + log "Set GO2_IP, then rerun this script before touching the real robot." +fi + +if [[ "${RUN_GO2_SMOKE:-0}" == "1" ]]; then + if [[ -z "${GO2_IP:-}" ]]; then + log "FAILED: RUN_GO2_SMOKE=1 requires GO2_IP" + exit 1 + fi + run_optional uv_run dimos stop --force + run uv_run dimos --viewer none run unitree-go2 -o "go2connection.ip=${GO2_IP}" --daemon + ROBOT_STARTED=1 + run uv_run dimos status + run uv_run dimos log -n 100 + run uv_run dimos stop --force + ROBOT_STARTED=0 +else + log "Base Go2 smoke not started. Use RUN_GO2_SMOKE=1 after the route is clear." +fi + +if [[ "${RUN_DOGOPS_SMOKE:-0}" == "1" ]]; then + if [[ -z "${GO2_IP:-}" ]]; then + log "FAILED: RUN_DOGOPS_SMOKE=1 requires GO2_IP" + exit 1 + fi + run_optional uv_run dimos stop --force + run uv_run dimos --viewer none run unitree-go2-dogops -o "go2connection.ip=${GO2_IP}" --daemon + ROBOT_STARTED=1 + run uv_run dimos status + log "+ uv_run dimos mcp list-tools" + uv_run dimos mcp list-tools >"${LOG_DIR}/mcp-tools.txt" 2>"${LOG_DIR}/mcp-tools.err" + for tool in "${DOGOPS_REQUIRED_TOOLS[@]}"; do + require_match "${tool}" "${LOG_DIR}/mcp-tools.txt" "DogOps MCP tool ${tool}" + done + run uv_run dimos mcp call run_mission --json-args '{"mission_id":"receiving_sre_demo"}' + run uv_run dimos mcp call scan_zone --json-args '{"zone_id":"INBOUND_DOCK"}' + run uv_run dimos mcp call scan_receiving_manifest --json-args '{"zone_id":"INBOUND_DOCK"}' + run uv_run dimos mcp call read_gauge --json-args '{"asset_id":"TEMP_1"}' + run uv_run dimos mcp call check_clearance --json-args '{"asset_id":"COOLING_1"}' + run uv_run dimos mcp call detect_blocked_aisle --json-args '{"zone_id":"AISLE_1"}' + run uv_run dimos mcp call route_status + run uv_run dimos mcp call nav_eval_report + run uv_run dimos log -n 200 + run uv_run dimos stop --force + ROBOT_STARTED=0 +else + log "DogOps hardware smoke not started. Use RUN_DOGOPS_SMOKE=1 only after base Go2 smoke passes." +fi + +log "DogOps Go2 preflight complete."