live: real-time browser viewer (AICC-client based), sensor-built top-down map shared module, run_live.sh one-command mode

This commit is contained in:
opencode
2026-08-08 12:12:21 +03:00
parent 1ebccb660b
commit 9f877339f8
5 changed files with 401 additions and 50 deletions
+255
View File
@@ -0,0 +1,255 @@
"""Real-time viewer: watch the capsule drive, live in your browser.
The viewer is itself a plain AICC client: it polls the bridge through the
protocol sensors (world_query once, proprioception + vision a few times per
second), renders a top-down map from what it observes, and pushes the frames
to any number of browser tabs over its own WebSocket.
Run it, then open http://127.0.0.1:8000 and start the demo in another
terminal (scripts/run_demo.sh) — you will see the capsule move in real time.
Usage:
python -m testbed.live [--bridge ws://127.0.0.1:8765] [--http-port 8000]
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import io
import json
import math
from typing import Any
from aicc.client import AICCClient
from aicc.transport.websocket import WebSocketClientTransport
from testbed.room.mapview import draw_sensor_map
HTTP_PORT = 8000
WS_PORT = 8001
POLL_SECONDS = 0.25 # ~4 fps
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>AICC capsule — live view</title>
<style>
body { background:#101016; color:#d8d8e0; font-family: ui-monospace, monospace; margin:0; }
header { padding:10px 18px; background:#181820; border-bottom:1px solid #2a2a36;
display:flex; gap:24px; align-items:baseline; font-size:13px; }
header h1 { font-size:15px; margin:0; color:#8ecbff; }
header .meta { color:#9aa0b0; }
main { display:flex; gap:14px; padding:14px; flex-wrap:wrap; }
.panel { background:#181820; border:1px solid #2a2a36; border-radius:8px; padding:10px; }
.panel h2 { margin:0 0 8px; font-size:12px; color:#ffb860; font-weight:600;
text-transform:uppercase; letter-spacing:.08em; }
img { display:block; image-rendering: pixelated; background:#000; }
#view { width: 400px; }
#map { width: 400px; }
#status { color:#9fe8a0; }
</style>
</head>
<body>
<header>
<h1>AICC capsule — real-time view</h1>
<span class="meta" id="status">connecting…</span>
</header>
<main>
<div class="panel"><h2>first person (vision)</h2><img id="view" alt="vision"></div>
<div class="panel"><h2>top-down (sensors)</h2><img id="map" alt="map"></div>
</main>
<script>
const view = document.getElementById('view');
const map = document.getElementById('map');
const status = document.getElementById('status');
let lastTick = 0, tickCount = 0, t0 = performance.now();
function show(msg, kind) {
status.textContent = msg;
status.style.color = kind === 'err' ? '#ff9090' : '#9fe8a0';
}
function connect() {
const ws = new WebSocket(`ws://${location.hostname}:${location.port === '8000' ? '8001' : location.port}`);
ws.onopen = () => show('connected — waiting for frames');
ws.onclose = () => { show('disconnected — retrying…', 'err'); setTimeout(connect, 1000); };
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'view') view.src = 'data:image/png;base64,' + m.png_b64;
if (m.type === 'map') {
map.src = 'data:image/png;base64,' + m.png_b64;
tickCount++;
const fps = tickCount / ((performance.now() - t0) / 1000);
show(`tick ${m.tick} pos (${m.pos[0].toFixed(2)}, ${m.pos[1].toFixed(2)}) ` +
`yaw ${m.yaw.toFixed(1)}° ${m.dist.toFixed(2)} m to beacon · ${fps.toFixed(1)} fps`);
}
};
}
connect();
</script>
</body>
</html>
"""
def _png_b64(img) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
class LiveViewer:
"""Polls the bridge via AICC sensors and broadcasts frames to browsers."""
def __init__(
self,
bridge_url: str,
http_port: int,
ws_port: int,
poll_seconds: float = POLL_SECONDS,
):
self.bridge_url = bridge_url
self.http_port = http_port
self.ws_port = ws_port
self.poll = poll_seconds
self.clients: set[Any] = set()
self.layout: dict[str, Any] | None = None
self.path: list[tuple[float, float]] = []
# -- sensor polling -----------------------------------------------------
async def _sense(self, client: AICCClient) -> None:
while True:
try:
if self.layout is None:
self.layout = (await client.call_tool("world_query", {})).output
prop = (await client.call_tool("proprioception", {})).output
vision = (await client.call_tool("vision", {})).output
pos = prop["position"]
yaw = prop["rotation"]["yaw_deg"] % 360.0
self.path.append((pos["x"], pos["z"]))
if len(self.path) > 4000:
self.path = self.path[-2000:]
beacon = self.layout.get("beacon", {}) if self.layout else {}
bx = beacon.get("x", 12.5)
bz = beacon.get("z", 12.5)
dist = math.hypot(bx - pos["x"], bz - pos["z"])
await self._broadcast(
{
"type": "view",
"png_b64": vision["png_b64"],
"tick": vision["tick"],
}
)
map_img = draw_sensor_map(
self.layout, (pos["x"], pos["z"]), yaw, self.path, step=prop["tick"]
)
await self._broadcast(
{
"type": "map",
"png_b64": _png_b64(map_img),
"tick": prop["tick"],
"pos": [pos["x"], pos["z"]],
"yaw": yaw,
"dist": dist,
}
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - keep polling through transient errors
print(f"[live] sense error: {type(exc).__name__}: {exc}")
await asyncio.sleep(self.poll)
async def _broadcast(self, msg: dict[str, Any]) -> None:
if not self.clients:
return
data = json.dumps(msg)
for ws in list(self.clients):
try:
await ws.send(data)
except Exception: # noqa: BLE001
self.clients.discard(ws)
# -- servers ------------------------------------------------------------
async def run(self) -> None:
import websockets
async def handler(ws) -> None:
self.clients.add(ws)
try:
await ws.wait_closed()
finally:
self.clients.discard(ws)
async def http_page(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
await reader.read(4096)
body = HTML.encode("utf-8")
writer.write(
b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
+ f"Content-Length: {len(body)}\r\n\r\n".encode()
+ body
)
await writer.drain()
finally:
writer.close()
async def bridge_loop() -> None:
while True:
try:
transport = WebSocketClientTransport(self.bridge_url)
async with AICCClient(transport) as client:
await client.handshake()
print(f"[live] connected to bridge {self.bridge_url}")
await self._sense(client)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - wait for the bridge
print(f"[live] bridge not reachable: {type(exc).__name__}: {exc}")
await asyncio.sleep(1.0)
ws_server = await websockets.serve(handler, "127.0.0.1", self.ws_port)
http_server = await asyncio.start_server(http_page, "127.0.0.1", self.http_port)
print(
f"[live] open http://127.0.0.1:{self.http_port} (frames on ws://127.0.0.1:{self.ws_port})"
)
print(
"[live] start the demo in another terminal: scripts/run_demo.sh --agent scripted"
)
poll_task = asyncio.create_task(bridge_loop())
try:
await asyncio.sleep(3600)
finally:
poll_task.cancel()
ws_server.close()
http_server.close()
def main() -> int:
parser = argparse.ArgumentParser(
description="Real-time browser viewer for the AICC capsule testbed."
)
parser.add_argument("--bridge", default="ws://127.0.0.1:8765")
parser.add_argument("--http-port", type=int, default=HTTP_PORT)
parser.add_argument("--ws-port", type=int, default=WS_PORT)
parser.add_argument(
"--poll",
type=float,
default=POLL_SECONDS,
help="sensor poll interval in seconds",
)
args = parser.parse_args()
viewer = LiveViewer(args.bridge, args.http_port, args.ws_port, args.poll)
try:
asyncio.run(viewer.run())
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())