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

1063 lines
39 KiB
Python

"""
HTTP + WebSocket server for the AGIBOT X2 dashboard.
Binds 0.0.0.0 so the dashboard answers on every interface this machine holds.
The browser derives its API and WebSocket URLs from window.location, so opening
the page from a laptop, a phone, or a tablet all work with no configuration and
no hardcoded address anywhere.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import re
import time
from pathlib import Path
from typing import Any
from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from . import netinfo, settings, voice_session, x2_spec
from .bridge_agent import AgentBridge
from .bridge_mock import MockBridge
from .hub import Hub
from .registry import Registry
ROOT = Path(__file__).resolve().parent.parent
WEB_DIR = ROOT / "web"
app = FastAPI(title="AGIBOT X2 Dashboard", version="1.0.0", docs_url="/api/docs")
# The dashboard is meant to be opened from any device on the LAN, so the browser
# origin varies with whichever IP was used to reach it.
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://.*",
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def track_clients(request: Request, call_next):
"""
Remember which devices have actually reached this server.
This is the only way to tell, without standing next to the person holding
the phone, whether a failure is "the phone cannot resolve the name" or "the
phone cannot reach this machine at all". The first is an mDNS/multicast
problem; the second is the network or the firewall. They need opposite fixes.
"""
client = request.client.host if request.client else None
if client and client not in ("127.0.0.1", "::1"):
seen = runtime.clients_seen.setdefault(client, {"first": time.time(), "hits": 0})
seen["hits"] += 1
seen["last"] = time.time()
seen["agent"] = (request.headers.get("user-agent") or "")[:120]
return await call_next(request)
@app.middleware("http")
async def no_stale_frontend(request: Request, call_next):
"""
Stop browsers serving a stale frontend.
The dashboard ships unversioned filenames, so a browser that cached
/js/tabs/vision.js will happily keep running it against a newer API - which
is exactly how the Vision tab ended up throwing "Cannot convert undefined or
null to object" after the camera list changed shape.
`no-store` rather than `no-cache`: no-cache still permits the browser to
keep the entry and reuse it under heuristic freshness, which is how a copy
stored *before* these headers existed survives a normal reload. no-store
forbids storing it at all. Combined with the build-stamped import URLs in
version_js() below, one ordinary reload is always enough.
API responses are untouched; they set their own caching.
"""
response = await call_next(request)
path = request.url.path
if path.startswith("/model/"):
# The exception. /model/model.bin is 1.4 MB of baked geometry that only
# changes when build_model.py is re-run, and the Twin tab fetches it on
# every visit. Re-downloading that over the robot's Wi-Fi to redraw the
# same unchanged robot is precisely the waste the rest of this dashboard
# goes out of its way to avoid.
response.headers["Cache-Control"] = "public, max-age=604800"
elif not path.startswith("/api/") and path != "/ws":
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
# --------------------------------------------------------------------------
# Frontend serving with build-stamped module URLs
# --------------------------------------------------------------------------
# Matches the module specifiers this codebase uses: `from './core.js'`,
# `from '../ui.js'`, `import('./tabs/x.js')`. Deliberately narrow - it only
# touches relative paths ending in .js that sit in an import position.
_IMPORT_RE = re.compile(
r"""(\bfrom\s+|\bimport\s*\(\s*)(['"])(\.{1,2}/[^'"]+?\.js)\2"""
)
def frontend_build() -> str:
"""A short hash of every file under web/, so any edit changes the URL."""
parts = []
for path in sorted(WEB_DIR.rglob("*")):
if path.is_file():
stat = path.stat()
parts.append(f"{path.name}:{stat.st_mtime_ns}:{stat.st_size}")
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:12]
def version_js(source: str, build: str) -> str:
"""
Stamp every relative import with the current build.
Versioning only the entry point is not enough: the browser would fetch a
fresh main.js and then satisfy its `./core.js` import from cache. Rewriting
the whole module graph means one navigation pulls a consistent set.
"""
return _IMPORT_RE.sub(
lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}?b={build}{m.group(2)}",
source,
)
def _safe_web_path(*parts: str) -> Path:
target = (WEB_DIR.joinpath(*parts)).resolve()
if not str(target).startswith(str(WEB_DIR.resolve())):
raise HTTPException(status_code=404, detail="Not found")
return target
@app.get("/js/{path:path}")
async def serve_module(path: str):
target = _safe_web_path("js", path)
if not target.is_file() or target.suffix != ".js":
raise HTTPException(status_code=404, detail="Not found")
body = version_js(target.read_text(encoding="utf-8"), frontend_build())
return Response(body, media_type="text/javascript; charset=utf-8",
headers={"Cache-Control": "no-store"})
class Runtime:
"""Holds the live objects the request handlers reach for."""
def __init__(self) -> None:
config = settings.load()
self.hub = Hub(history_seconds=config["history_seconds"],
sample_hz=config["telemetry_hz"])
self.registry = Registry(self.hub, config)
self.bridge = None
self.bridge_error = ""
self.started_at = time.time()
self._scan_task: asyncio.Task | None = None
self.announcer = None
self.clients_seen: dict[str, dict] = {}
# -- name advertisement -------------------------------------------------
def start_announcer(self) -> None:
"""
Publish <dashboard_name>.local so the link is about the robot.
Deliberately runs inside this process rather than as a helper: on
Windows the inbound firewall rules for UDP 5353 are program-scoped, and
this interpreter is the one already permitted to receive traffic from
other devices. A helper under a different interpreter would answer every
local test and still be invisible to a phone.
"""
config = settings.load()
if not config.get("advertise_name"):
return
name = (config.get("dashboard_name") or "").strip()
if not name:
return
from .announce import Announcer
self.announcer = Announcer(
name=name,
port=int(config.get("port") or 8770),
address_provider=netinfo.primary_address,
)
if not self.announcer.start():
# Not fatal - the IP and the PC's own name still work.
self.hub.log("warn", "network",
f"Could not advertise {self.announcer.name}: {self.announcer.error}")
def stop_announcer(self) -> None:
if self.announcer is not None:
self.announcer.stop()
self.announcer = None
# -- live address watching ----------------------------------------------
async def watch_address(self) -> None:
"""
Re-check the LAN address continuously and push changes to open pages.
The published URL is only useful if it is current. Roaming to another
Wi-Fi, a DHCP renewal or simply unplugging changes it, and a browser
left open on the Settings tab would otherwise keep showing an address
that stopped working. Detection costs ~4 ms and is cached for 2 s, so
this loop is effectively free.
"""
last: str | None = None
misses = 0
while True:
try:
await asyncio.sleep(3.0)
detail = await asyncio.to_thread(netinfo.address_detail)
current = detail["ip"]
# A brief gap is normal during a DHCP renewal or a roam, so do
# not announce "no network" on the first empty read. But never
# keep showing an address we know is gone.
if current is None:
misses += 1
if misses < 2 and last is not None:
continue
else:
misses = 0
if current == last:
continue
if last is not None:
where = current or detail["reason_text"] or "no network"
await self.hub.emit("info", "network",
f"Dashboard address changed: {last} -> {where}")
last = current
config = settings.load()
await self.hub.broadcast("network", {
"urls": netinfo.dashboard_urls(config["port"],
config.get("dashboard_name")),
"address": detail,
"port": config["port"],
})
# Keep the advertised name pointing at the new address.
if self.announcer is not None and current:
self.announcer.ip = current
except asyncio.CancelledError:
raise
except Exception as exc:
log_once = getattr(self, "_watch_warned", False)
if not log_once:
self._watch_warned = True
await self.hub.emit("warn", "network",
f"Address watcher error: {exc}")
await asyncio.sleep(5.0)
# -- bridge selection ---------------------------------------------------
async def start_bridge(self) -> None:
config = settings.load()
mode = config.get("bridge_mode", "auto")
self.bridge_error = ""
if mode in ("auto", "agent"):
# With auto-discovery on we can start without a saved address - the
# bridge sweeps the network for the robot itself.
if config.get("robot_host") or config.get("auto_discover"):
try:
self.bridge = AgentBridge(self.hub, config)
await self.bridge.start()
self.registry.attach(self.bridge)
return
except Exception as exc:
self.bridge_error = str(exc)
self.bridge = None
await self.hub.emit("error", "bridge", f"Agent bridge failed: {exc}")
if mode == "agent":
raise
else:
self.bridge_error = "No robot host is configured."
if mode == "agent":
raise RuntimeError(
"bridge_mode is 'agent' but no robot_host is set. "
"Set it in the Settings tab or pass --robot <ip>."
)
await self.hub.emit(
"warn", "bridge",
"No robot host configured - running in simulation. "
"Set the robot address in Settings to attach to the real X2.",
)
self.bridge = MockBridge(self.hub, config)
await self.bridge.start()
self.registry.attach(self.bridge)
async def stop_bridge(self) -> None:
if self.bridge is not None:
with contextlib.suppress(Exception):
await self.bridge.stop()
self.bridge = None
async def restart_bridge(self) -> dict:
await self.stop_bridge()
try:
await self.start_bridge()
except Exception as exc:
self.bridge_error = str(exc)
return {"ok": False, "message": str(exc)}
await self.registry.load()
return {"ok": True, "message": f"Bridge restarted ({self.bridge.name})",
"detail": {"transport": self.bridge.name}}
runtime = Runtime()
def require_bridge():
if runtime.bridge is None:
raise HTTPException(status_code=503, detail="No bridge is running. Check the Settings tab.")
return runtime.bridge
def result(command_result) -> JSONResponse:
payload = command_result.as_dict()
return JSONResponse(payload, status_code=200 if payload["ok"] else 400)
# --------------------------------------------------------------------------
# Lifecycle
# --------------------------------------------------------------------------
@app.on_event("startup")
async def _startup() -> None:
runtime.start_announcer()
runtime.address_task = asyncio.create_task(runtime.watch_address(), name="address-watch")
await runtime.start_bridge()
await runtime.registry.load()
@app.on_event("shutdown")
async def _shutdown() -> None:
task = getattr(runtime, "address_task", None)
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
runtime.stop_announcer()
await runtime.registry.shutdown()
await runtime.stop_bridge()
# --------------------------------------------------------------------------
# Bootstrap and state
# --------------------------------------------------------------------------
@app.get("/api/bootstrap")
async def bootstrap() -> dict:
"""One request the page makes on load: spec, config, state, plugins, network."""
config = settings.load()
return {
"spec": x2_spec.client_spec(),
"settings": settings.describe(),
"state": runtime.bridge.snapshot() if runtime.bridge else None,
"plugins": runtime.registry.manifest(),
"events": runtime.hub.events(120),
"bridge": {
"name": runtime.bridge.name if runtime.bridge else "none",
"simulated": runtime.bridge.simulated if runtime.bridge else True,
"online": bool(runtime.bridge and runtime.bridge.state.connection.online),
"error": runtime.bridge_error or (
runtime.bridge.state.connection.error if runtime.bridge else ""),
"agent": (runtime.bridge.state.custom.get("agent")
if runtime.bridge else None),
"host": config.get("robot_host", ""),
"agent_port": config.get("agent_port", x2_spec.AGENT_PORT),
},
"network": {
"addresses": netinfo.local_addresses(),
"networks": netinfo.local_networks(),
"urls": netinfo.dashboard_urls(config["port"], config.get("dashboard_name")),
"address": netinfo.address_detail(),
"advertised": runtime.announcer.status() if runtime.announcer else None,
"port": config["port"],
},
"server": {
"version": app.version,
"uptime_s": round(time.time() - runtime.started_at, 1),
"clients": runtime.hub.client_count,
},
}
@app.get("/api/state")
async def get_state() -> dict:
return require_bridge().snapshot()
@app.get("/api/series")
async def get_series(keys: str = "", limit: int = 600) -> dict:
if keys:
wanted = [k.strip() for k in keys.split(",") if k.strip()]
return {k: runtime.hub.series(k, limit) for k in wanted}
return runtime.hub.all_series(limit)
@app.get("/api/series/keys")
async def series_keys() -> dict:
return {"keys": runtime.hub.series_keys()}
@app.get("/api/events")
async def get_events(limit: int = 200) -> dict:
return {"events": runtime.hub.events(limit)}
@app.get("/api/topics")
async def get_topics(graph: bool = False) -> dict:
"""Topic liveness, plus the full ROS graph on request (it is a heavy call)."""
bridge = require_bridge()
payload = {
"tracked": await bridge.list_topics(),
"documented": x2_spec.SENSOR_TOPICS,
"graph": {"topics": [], "services": [], "nodes": []},
}
if graph and hasattr(bridge, "graph"):
try:
payload["graph"] = await bridge.graph()
except Exception as exc:
payload["graph_error"] = str(exc)
return payload
@app.get("/api/health")
async def health() -> dict:
bridge = runtime.bridge
return {
"ok": bridge is not None and bridge.state.connection.online,
"bridge": bridge.name if bridge else "none",
"simulated": bridge.simulated if bridge else True,
"clients": runtime.hub.client_count,
"uptime_s": round(time.time() - runtime.started_at, 1),
}
# --------------------------------------------------------------------------
# Settings and networking
# --------------------------------------------------------------------------
@app.get("/api/settings")
async def get_settings() -> dict:
return settings.describe()
@app.post("/api/settings")
async def post_settings(payload: dict = Body(...)) -> dict:
before = settings.load()
after = settings.save(payload)
# Changing any of these means the bridge has to be rebuilt to take effect.
restart_keys = ("robot_host", "ros_domain_id", "rmw_implementation", "bridge_mode")
needs_restart = any(before.get(k) != after.get(k) for k in restart_keys)
if runtime.bridge is not None:
await runtime.bridge.reconfigure(after)
runtime.registry.config = after
await runtime.hub.broadcast("settings", settings.describe())
return {"ok": True, "settings": settings.describe(), "needs_restart": needs_restart}
@app.get("/api/network")
async def get_network() -> dict:
config = settings.load()
local = {a["address"] for a in netinfo.local_addresses()}
others = [
{"address": ip, **info}
for ip, info in sorted(runtime.clients_seen.items())
if ip not in local and ip != config.get("robot_host")
]
return {
"addresses": netinfo.local_addresses(),
"networks": netinfo.local_networks(),
"urls": netinfo.dashboard_urls(config["port"], config.get("dashboard_name")),
"address": netinfo.address_detail(),
"advertised": runtime.announcer.status() if runtime.announcer else None,
# Devices other than this PC that have loaded the dashboard. Proof of
# what a phone can and cannot reach.
"other_devices": others,
"port": config["port"],
"robot_host": config["robot_host"],
"pc1_warning": x2_spec.PC1_MOTION_CONTROL_IP,
}
@app.post("/api/network/probe")
async def post_probe(payload: dict = Body(...)) -> dict:
host = str(payload.get("host", "")).strip()
if not host:
raise HTTPException(status_code=400, detail="host is required")
resolved = await netinfo.resolve(host)
if resolved is None:
return {"ok": False, "message": f"Could not resolve '{host}'"}
info = await netinfo.probe_host(resolved)
info["input"] = host
return {"ok": info["reachable"], "detail": info,
"message": (f"{resolved} answered on ports {info['open_ports']}"
if info["reachable"] else f"{resolved} did not answer on any known port")}
@app.post("/api/network/scan")
async def post_scan(payload: dict = Body(default={})) -> dict:
"""
Sweep a subnet for the robot. Defaults to whichever networks this machine is
on right now, so moving between Wi-Fi networks needs no reconfiguration.
"""
cidrs = payload.get("networks") or netinfo.local_networks()
if not cidrs:
return {"ok": False, "message": "No usable network interface found", "hosts": []}
async def progress(done: int, total: int) -> None:
await runtime.hub.broadcast("scan_progress", {"done": done, "total": total})
found: list[dict] = []
for cidr in cidrs[:3]:
try:
found.extend(await netinfo.scan_subnet(cidr, progress=progress))
except ValueError as exc:
await runtime.hub.emit("warn", "network", str(exc))
except Exception as exc:
await runtime.hub.emit("error", "network", f"Scan of {cidr} failed: {exc}")
local = {a["address"] for a in netinfo.local_addresses()}
for host in found:
host["is_self"] = host["host"] in local
await runtime.hub.emit("info", "network",
f"Scan finished: {len(found)} responding host(s) across {len(cidrs[:3])} network(s)")
return {"ok": True, "hosts": found, "networks": cidrs[:3],
"message": f"{len(found)} host(s) responding"}
@app.post("/api/bridge/restart")
async def post_bridge_restart() -> dict:
return await runtime.restart_bridge()
# --------------------------------------------------------------------------
# Robot commands
# --------------------------------------------------------------------------
@app.post("/api/mode")
async def post_mode(payload: dict = Body(...)):
mode = str(payload.get("mode", ""))
if mode == "PASSIVE_DEFAULT" and settings.get("require_confirm_zero_torque", True):
if not payload.get("confirmed"):
raise HTTPException(
status_code=428,
detail="Zero-torque drops all holding force. Re-send with confirmed=true.",
)
return result(await require_bridge().set_mode(mode))
@app.get("/api/mode")
async def get_mode():
return result(await require_bridge().get_mode())
@app.post("/api/velocity")
async def post_velocity(payload: dict = Body(...)):
config = settings.load()
def clamp(value: Any, cap: float) -> float:
try:
value = float(value)
except (TypeError, ValueError):
return 0.0
return max(-cap, min(cap, value))
return result(await require_bridge().set_velocity(
clamp(payload.get("forward", 0), config["max_forward_velocity"]),
clamp(payload.get("lateral", 0), config["max_lateral_velocity"]),
clamp(payload.get("angular", 0), config["max_angular_velocity"]),
))
@app.post("/api/stop")
async def post_stop():
bridge = require_bridge()
outcome = await bridge.stop_motion()
await runtime.hub.emit("warn", "control", "Stop issued from dashboard")
return result(outcome)
@app.post("/api/preset")
async def post_preset(payload: dict = Body(...)):
key = payload.get("key")
if key:
preset = next((p for p in x2_spec.PRESET_MOTIONS if p["key"] == key), None)
if preset is None:
raise HTTPException(status_code=400, detail=f"Unknown preset '{key}'")
motion, area = preset["motion"], preset["area"]
else:
motion, area = int(payload.get("motion", 0)), int(payload.get("area", 0))
return result(await require_bridge().play_preset(
motion, area, bool(payload.get("interrupt", True))
))
@app.post("/api/joints")
async def post_joints(payload: dict = Body(...)):
group = str(payload.get("group", ""))
mode = str(payload.get("mode", "position"))
targets = payload.get("targets") or {}
if not isinstance(targets, dict):
raise HTTPException(status_code=400, detail="targets must be an object of joint -> value")
return result(await require_bridge().set_joints(
group, mode, targets,
payload.get("stiffness"), payload.get("damping"),
))
@app.post("/api/hand")
async def post_hand(payload: dict = Body(...)):
side = str(payload.get("side", "right"))
preset_key = payload.get("preset")
if preset_key:
preset = next((p for p in x2_spec.HAND_PRESETS if p["key"] == preset_key), None)
if preset is None:
raise HTTPException(status_code=400, detail=f"Unknown hand preset '{preset_key}'")
positions = preset["positions"]
else:
positions = payload.get("positions") or []
return result(await require_bridge().set_hand(side, [float(v) for v in positions]))
@app.post("/api/speak")
async def post_speak(payload: dict = Body(...)):
return result(await require_bridge().speak(
str(payload.get("text", "")),
int(payload.get("priority", 6)),
bool(payload.get("interrupt", False)),
))
@app.post("/api/volume")
async def post_volume(payload: dict = Body(...)):
return result(await require_bridge().set_volume(int(payload.get("volume", 60))))
@app.post("/api/mute")
async def post_mute(payload: dict = Body(...)):
return result(await require_bridge().set_mute(bool(payload.get("muted", False))))
# --------------------------------------------------------------------------
# Voice session - the conversational loop (Muza / Lumi)
# --------------------------------------------------------------------------
@app.get("/api/voice/session")
async def get_voice_session() -> dict:
return await asyncio.to_thread(voice_session.snapshot)
@app.post("/api/voice/session")
async def post_voice_session(payload: dict = Body(...)) -> dict:
"""Apply the operator's selection, then greet if it was switched on.
systemctl blocks for a second or two, so it runs off the event loop - the
telemetry websocket must keep flowing while the unit restarts.
"""
outcome = await asyncio.to_thread(
voice_session.apply,
bool(payload.get("enabled", False)),
payload.get("gender"),
payload.get("language"),
payload.get("model"),
)
if not outcome.get("ok"):
raise HTTPException(status_code=400, detail=outcome.get("error", "apply failed"))
# The dashboard deliberately does NOT speak the greeting. Both voice paths
# now render it themselves in the selected character's voice - Gemini in
# gemini/script.py, LinkSoul in linksoul/runner.py - because the only tool
# available here is the robot's built-in PlayTts, whose request message has
# `voice_id` commented out by the vendor. That engine has exactly one voice,
# so it announced the female character in a male voice on every Apply.
await runtime.hub.broadcast("voice_session", outcome["snapshot"])
return outcome
@app.post("/api/emoji")
async def post_emoji(payload: dict = Body(...)):
return result(await require_bridge().play_emoji(
int(payload.get("emotion_id", 1)),
int(payload.get("mode", 1)),
int(payload.get("priority", 6)),
))
@app.post("/api/led")
async def post_led(payload: dict = Body(...)):
def channel(name: str) -> int:
return max(0, min(255, int(payload.get(name, 0))))
# keep=True asks the bridge to re-assert this setting periodically, because
# the robot's task_manager reclaims the strip on its own after about a
# minute. See led_keepalive() in agent/x2_agent.py.
return result(await require_bridge().set_led(
int(payload.get("mode", 0)), channel("r"), channel("g"), channel("b"),
int(payload.get("priority", x2_spec.LED_DEFAULT_PRIORITY)),
keep=bool(payload.get("keep", True)),
))
@app.post("/api/input-source")
async def post_input_source(payload: dict = Body(...)):
source = x2_spec.DASHBOARD_INPUT_SOURCE
return result(await require_bridge().register_input_source(
str(payload.get("name", source["name"])),
int(payload.get("priority", source["priority"])),
int(payload.get("timeout", source["timeout"])),
))
@app.post("/api/raw/publish")
async def post_raw_publish(payload: dict = Body(...)):
return result(await require_bridge().publish_raw(
str(payload.get("topic", "")),
str(payload.get("type", "")),
payload.get("fields") or {},
))
@app.post("/api/raw/service")
async def post_raw_service(payload: dict = Body(...)):
return result(await require_bridge().call_service(
str(payload.get("service", "")),
str(payload.get("type", "")),
payload.get("fields") or {},
))
# --------------------------------------------------------------------------
# Camera
# --------------------------------------------------------------------------
@app.get("/api/streams")
async def get_streams() -> dict:
"""Which feeds are subscribed on the robot right now."""
bridge = require_bridge()
outcome = await bridge.list_streams()
return {
"ok": outcome.ok,
"message": outcome.message,
"streams": (outcome.detail or {}).get("streams", {}) if outcome.ok else {},
"cameras": x2_spec.CAMERAS,
"lidar": x2_spec.LIDAR,
}
@app.post("/api/streams/{key}")
async def post_stream(key: str, payload: dict = Body(default={})):
"""
Switch a camera or the LiDAR on or off.
Nothing is subscribed on the robot until this is called. A camera frame here
is 170-430 KB and the LiDAR pushes 816 KB a scan, so leaving them all
running would compete with the robot's own traffic for pictures nobody is
looking at.
"""
known = {c["key"] for c in x2_spec.CAMERAS} | {x2_spec.LIDAR["key"]}
if key not in known:
raise HTTPException(status_code=404, detail=f"Unknown stream '{key}'")
return result(await require_bridge().set_stream(key, bool(payload.get("active", False))))
@app.get("/api/lidar/points")
async def get_lidar_points():
outcome = await require_bridge().lidar_points()
if not outcome.ok:
# "Off" is an ordinary state, not a failure - the browser polls this and
# a 4xx per poll would fill the console for something working correctly.
return JSONResponse({"ok": False, "message": outcome.message,
"off": bool((outcome.detail or {}).get("off")),
"points": []}, status_code=200)
return JSONResponse({"ok": True, **(outcome.detail or {})})
@app.get("/api/camera/{camera_key}/frame")
async def camera_frame(camera_key: str, stream: str = "rgb", flip: bool = False):
bridge = require_bridge()
if not any(c["key"] == camera_key for c in x2_spec.CAMERAS):
raise HTTPException(status_code=404, detail=f"Unknown camera '{camera_key}'")
data = await bridge.camera_frame(camera_key, stream, flip=flip)
if not data:
# 204, not an error status. A camera whose topic has no publisher is a
# normal state on this robot (the perception debug feeds only run with
# that module), and an error status makes every browser log a failed
# request several times a second for something that is working as
# intended. 404 above still covers a camera that does not exist.
return Response(status_code=204, headers={"Cache-Control": "no-store"})
content_type = "image/jpeg"
getter = getattr(bridge, "frame_content_type", None)
if callable(getter):
try:
content_type = getter(camera_key, stream)
except TypeError:
content_type = getter()
return Response(content=data, media_type=content_type,
headers={"Cache-Control": "no-store"})
@app.get("/api/qr")
async def qr_code(url: str = "") -> Response:
"""
A QR code for a dashboard URL.
Typing an IP address into a phone is exactly the friction that makes people
want a name in the first place. A QR of the numeric URL sidesteps the whole
naming question: it needs no mDNS, no multicast and no DNS, so it works even
on a Wi-Fi that isolates clients from each other.
"""
config = settings.load()
if not url:
address = netinfo.primary_address()
if not address:
raise HTTPException(status_code=503, detail="No LAN address to encode")
url = f"http://{address}:{config['port']}"
try:
import io
import qrcode
import qrcode.image.svg
except ImportError:
raise HTTPException(
status_code=501,
detail="qrcode is not installed - run: pip install -r requirements.txt",
)
image = qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, box_size=10, border=2)
buffer = io.BytesIO()
image.save(buffer)
return Response(buffer.getvalue(), media_type="image/svg+xml",
headers={"Cache-Control": "no-store"})
@app.post("/api/robot/find")
async def robot_find() -> dict:
"""
Sweep the current network for the robot, from the offline screen.
Reports both hosts already running the agent and hosts that merely answer
on SSH, because the second kind can usually be started.
"""
from . import recovery
config = settings.load()
port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
ssh_port = int(config.get("robot_ssh_port") or 22)
candidates = await recovery.find_robot(port, ssh_port)
local = {a["address"] for a in netinfo.local_addresses()}
for candidate in candidates:
candidate["is_self"] = candidate["host"] in local
return {
"ok": True,
"candidates": [c for c in candidates if not c["is_self"]],
"current": config.get("robot_host", ""),
"agent_port": port,
}
@app.post("/api/robot/wake")
async def robot_wake(payload: dict = Body(default={})) -> dict:
"""Log into the robot and start the agent, then wait for it to come up."""
from . import recovery
config = settings.load()
host = str(payload.get("host") or config.get("robot_host") or "").strip()
if not host:
raise HTTPException(status_code=400, detail="No robot address to start the agent on")
port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
ok, message = await recovery.start_agent(host, config)
if not ok:
return {"ok": False, "message": message}
came_up = await recovery.wait_for_agent(host, port, timeout=45.0)
if came_up and host != config.get("robot_host"):
settings.save({"robot_host": host})
await runtime.hub.broadcast("settings", settings.describe())
return {
"ok": came_up,
"message": ("Agent started" if came_up
else "Start command ran, but the agent has not come up yet"),
"host": host,
}
@app.get("/api/robot/status")
async def robot_status() -> dict:
"""
Cheap liveness probe the browser polls while the robot is off, so the
offline gate can clear itself the moment the robot comes back.
"""
bridge = runtime.bridge
connection = bridge.state.connection if bridge else None
config = settings.load()
return {
"online": bool(connection and connection.online),
"simulated": bool(bridge and bridge.simulated),
"host": config.get("robot_host", ""),
"agent_port": config.get("agent_port", x2_spec.AGENT_PORT),
"error": connection.error if connection else "No bridge running",
"uptime_s": round(connection.uptime_s, 1) if connection else 0,
"mode": bridge.state.mode if bridge else None,
# What the automatic recovery is doing right now, so the offline screen
# can show progress instead of a static "waiting".
"recovery": (bridge.state.custom.get("recovery") or "") if bridge else "",
"auto_discover": bool(config.get("auto_discover")),
"auto_start_agent": bool(config.get("auto_start_agent")),
"has_ssh_password": bool(config.get("robot_ssh_password")),
}
# --------------------------------------------------------------------------
# Plugins
# --------------------------------------------------------------------------
@app.get("/api/plugins")
async def get_plugins() -> dict:
return runtime.registry.manifest()
@app.post("/api/plugins/reload")
async def reload_plugins() -> dict:
summary = await runtime.registry.load()
manifest = runtime.registry.manifest()
await runtime.hub.broadcast("plugins", manifest)
return {"ok": True, "summary": summary, "manifest": manifest}
@app.post("/api/plugins/{plugin_id}/{control_key}")
async def run_plugin_control(plugin_id: str, control_key: str, payload: dict = Body(default={})):
outcome = await runtime.registry.dispatch(plugin_id, control_key, payload.get("value"))
return JSONResponse(outcome, status_code=200 if outcome.get("ok") else 400)
# --------------------------------------------------------------------------
# WebSocket
# --------------------------------------------------------------------------
@app.websocket("/ws")
async def websocket_endpoint(socket: WebSocket) -> None:
await socket.accept()
queue = await runtime.hub.register()
try:
if runtime.bridge is not None:
await socket.send_json({"type": "state", "ts": time.time(),
"data": runtime.bridge.snapshot()})
async def pump() -> None:
while True:
message = await queue.get()
await socket.send_text(message)
sender = asyncio.create_task(pump())
try:
while True:
# Inbound messages are the joystick's high-rate path - going over
# the socket avoids an HTTP round trip per frame.
message = await socket.receive_json()
await _handle_socket_message(socket, message)
finally:
sender.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sender
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
await runtime.hub.unregister(queue)
async def _handle_socket_message(socket: WebSocket, message: dict) -> None:
kind = message.get("type")
if kind == "ping":
await socket.send_json({"type": "pong", "ts": time.time(),
"data": {"echo": message.get("data")}})
return
if kind == "velocity" and runtime.bridge is not None:
data = message.get("data") or {}
config = settings.load()
def clamp(value, cap):
try:
return max(-cap, min(cap, float(value)))
except (TypeError, ValueError):
return 0.0
outcome = await runtime.bridge.set_velocity(
clamp(data.get("forward", 0), config["max_forward_velocity"]),
clamp(data.get("lateral", 0), config["max_lateral_velocity"]),
clamp(data.get("angular", 0), config["max_angular_velocity"]),
)
if not outcome.ok:
await socket.send_json({"type": "command_error", "ts": time.time(),
"data": {"source": "velocity", "message": outcome.message}})
return
if kind == "stop" and runtime.bridge is not None:
await runtime.bridge.stop_motion()
return
# --------------------------------------------------------------------------
# Static frontend - mounted last so /api routes win
# --------------------------------------------------------------------------
@app.get("/")
async def index() -> Response:
"""Serve index.html with the entry module stamped with the current build."""
build = frontend_build()
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
html = html.replace('src="/js/main.js"', f'src="/js/main.js?b={build}"')
# The placeholder is {{BUILD}}, not __BUILD__: replacing the latter would
# also rewrite the `window.__BUILD__` identifier on the same line and
# produce `window.<hash> = "<hash>"`.
html = html.replace("{{BUILD}}", build)
return Response(html, media_type="text/html; charset=utf-8",
headers={"Cache-Control": "no-store"})
@app.get("/favicon.ico")
async def favicon() -> Response:
return Response(status_code=204)
if WEB_DIR.exists():
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")