1063 lines
39 KiB
Python
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")
|