228 lines
7.9 KiB
Python
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),
|
|
}
|