""" Runtime configuration, persisted to config.json beside the project root. The robot address lives here rather than in code, and every field can be changed from the Settings tab while the server is running. """ from __future__ import annotations import json import os import threading from pathlib import Path from typing import Any from . import x2_spec ROOT = Path(__file__).resolve().parent.parent CONFIG_PATH = ROOT / "config.json" _lock = threading.Lock() DEFAULTS: dict[str, Any] = { # Where the on-robot agent is reachable. Empty means "not yet configured" - # the Settings tab prompts for it and the discovery scan can fill it in. # Deliberately not a compiled-in address. "robot_host": "", "agent_port": x2_spec.AGENT_PORT, "robot_label": "AGIBOT X2", # The name the dashboard advertises for itself on the network, so the link # people type is about the robot rather than whatever the PC happens to be # called. Served as .local via mDNS - see backend/announce.py. "dashboard_name": "agibot", "advertise_name": True, # Finding the robot again after a power cycle. # # auto_discover: when the saved address stops answering, sweep the current # subnets for a host running the agent (or reachable over SSH) and adopt # it. Handles the robot getting a different DHCP lease. # auto_start_agent: if the robot answers on SSH but the agent is not # running, log in and start it. The robot cannot reliably start it by # itself - the agi account cannot enable systemd lingering, and its clock # jumps backwards after boot, which stalls cron. "auto_discover": True, "auto_start_agent": True, "robot_ssh_user": "agi", "robot_ssh_password": "", "robot_ssh_port": 22, "agent_start_command": "/home/agi/x2_dashboard_agent/ensure_agent.sh", # ROS 2 settings are used by the agent on the robot, and shown here for # reference so the operator can confirm both ends agree. "ros_domain_id": x2_spec.DEFAULT_ROS_DOMAIN_ID, "rmw_implementation": "rmw_fastrtps_cpp", # "auto" - talk to the robot agent if a host is set, else simulate # "agent" - require the robot agent, never fall back to simulation # "mock" - always simulate, for UI work away from the robot "bridge_mode": "auto", # HTTP server "host": "0.0.0.0", "port": 8770, # Telemetry "telemetry_hz": 10, "history_seconds": 120, # Safety "require_confirm_zero_torque": True, "locomotion_deadman_s": x2_spec.LOCOMOTION_DEADMAN_S, "max_forward_velocity": x2_spec.VELOCITY_LIMITS["forward"]["max"], "max_lateral_velocity": x2_spec.VELOCITY_LIMITS["lateral"]["max"], "max_angular_velocity": x2_spec.VELOCITY_LIMITS["angular"]["max"], # UI "theme": "dark", "accent": "blue", } # Fields the browser is allowed to change. WRITABLE = set(DEFAULTS) - {"host"} _cache: dict[str, Any] | None = None # Keys an environment variable may force for a single run. These must survive a # save() that re-reads the file, or the forced value would be lost mid-session. _ENV_FORCED: set[str] = set() def _read_disk() -> dict[str, Any]: if not CONFIG_PATH.exists(): return {} try: with CONFIG_PATH.open("r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, dict) else {} except (OSError, json.JSONDecodeError): return {} def load() -> dict[str, Any]: """Current configuration: defaults <- config.json <- environment.""" global _cache with _lock: if _cache is None: merged = dict(DEFAULTS) merged.update({k: v for k, v in _read_disk().items() if k in DEFAULTS}) # Environment wins, so `X2_ROBOT_HOST=... python -m backend` works # for one-off runs without editing the saved config. if os.environ.get("X2_ROBOT_HOST"): merged["robot_host"] = os.environ["X2_ROBOT_HOST"] _ENV_FORCED.add("robot_host") if os.environ.get("X2_PORT"): try: merged["port"] = int(os.environ["X2_PORT"]) _ENV_FORCED.add("port") except ValueError: pass if os.environ.get("ROS_DOMAIN_ID"): try: merged["ros_domain_id"] = int(os.environ["ROS_DOMAIN_ID"]) _ENV_FORCED.add("ros_domain_id") except ValueError: pass if os.environ.get("X2_BRIDGE_MODE") in ("auto", "agent", "mock"): merged["bridge_mode"] = os.environ["X2_BRIDGE_MODE"] _ENV_FORCED.add("bridge_mode") _cache = merged return dict(_cache) def get(key: str, fallback: Any = None) -> Any: return load().get(key, fallback) def save(updates: dict[str, Any]) -> dict[str, Any]: """ Apply and persist a partial update. Unknown keys are ignored. Merges against what is on disk RIGHT NOW rather than against this process's cached copy. The cache is per-process and never re-read, so a writer holding a stale copy would otherwise rewrite the whole file from it and silently revert anything another process had saved in the meantime - which is exactly how a saved SSH password disappears. Only the keys in `updates` are the caller's to change; everything else comes from disk. """ global _cache clean = _coerce(updates) with _lock: current = dict(DEFAULTS) current.update({k: v for k, v in _read_disk().items() if k in DEFAULTS}) # Environment overrides still win, so a value forced for this run is not # written back over the operator's saved configuration. if _cache is not None: for key in _ENV_FORCED: if key in _cache: current[key] = _cache[key] current.update(clean) _cache = current try: CONFIG_PATH.write_text( json.dumps(current, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) except OSError: # A read-only filesystem should not take the dashboard down; the # change still applies for this session. pass return dict(current) def _coerce(updates: dict[str, Any]) -> dict[str, Any]: """Validate and type-correct incoming settings from the browser.""" out: dict[str, Any] = {} for key, value in updates.items(): if key not in WRITABLE: continue default = DEFAULTS[key] try: if isinstance(default, bool): out[key] = bool(value) elif isinstance(default, int): out[key] = int(value) elif isinstance(default, float): out[key] = float(value) else: out[key] = str(value).strip() except (TypeError, ValueError): continue if "port" in out: out["port"] = max(1, min(65535, out["port"])) if "agent_port" in out: out["agent_port"] = max(1, min(65535, out["agent_port"])) if "ros_domain_id" in out: out["ros_domain_id"] = max(0, min(232, out["ros_domain_id"])) if "telemetry_hz" in out: out["telemetry_hz"] = max(1, min(60, out["telemetry_hz"])) if "bridge_mode" in out and out["bridge_mode"] not in ("auto", "agent", "mock"): out.pop("bridge_mode") for key, cap in ( ("max_forward_velocity", x2_spec.VELOCITY_LIMITS["forward"]["max"]), ("max_lateral_velocity", x2_spec.VELOCITY_LIMITS["lateral"]["max"]), ("max_angular_velocity", x2_spec.VELOCITY_LIMITS["angular"]["max"]), ): if key in out: out[key] = max(0.0, min(cap, out[key])) return out def describe() -> dict[str, Any]: """Config plus metadata the Settings tab renders.""" return { "values": load(), "defaults": DEFAULTS, "writable": sorted(WRITABLE), "config_path": str(CONFIG_PATH), }