2026-08-13 16:23:18 +04:00

228 lines
7.9 KiB
Python

"""
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 <name>.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),
}