From f1b7d24773b5d5e9256a3dd7e055e0178ac7e30e Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 18:42:03 -0400 Subject: [PATCH 001/126] Fix two hangs on quiet robots: recv-timeout reconnect loop, state() self-deadlock - rosbridge.py: a websocket recv timeout on a quiet socket (a sim robot with no /tf chatter) was treated as a disconnect, causing an infinite reconnect loop that never replays subscriptions. Timeouts now continue. - ros_camera.py: state() called is_active() while already holding the same non-reentrant lock, freezing every behavior's see() on first use. The freshness check is now inlined. --- roborun/ros_camera.py | 5 ++++- roborun/rosbridge.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/roborun/ros_camera.py b/roborun/ros_camera.py index 9b40980..927c4fa 100644 --- a/roborun/ros_camera.py +++ b/roborun/ros_camera.py @@ -138,7 +138,10 @@ def snapshot(self): def state(self) -> dict[str, Any]: with self._lock: - return {"topic": self._topic, "active": self.is_active(), + # inline the freshness check: is_active() takes this same + # non-reentrant lock, so calling it here would self-deadlock + return {"topic": self._topic, + "active": time.monotonic() - self._frame_ts < _FRESH, "frames": self._frames, "detections": len(self._detections)} diff --git a/roborun/rosbridge.py b/roborun/rosbridge.py index c6677a2..849e0dc 100644 --- a/roborun/rosbridge.py +++ b/roborun/rosbridge.py @@ -121,12 +121,15 @@ def _send(self, msg: dict) -> None: self._ws.send(json.dumps(msg)) def _recv_loop(self) -> None: + from websocket import WebSocketTimeoutException while self._connected and self._ws: try: raw = self._ws.recv() if not raw: break msg = json.loads(raw) + except WebSocketTimeoutException: + continue # a quiet socket is not a dead socket except Exception: break self._dispatch(msg) From e2e42aab406a80eaf80a0f692f800c50722cb673 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 18:42:12 -0400 Subject: [PATCH 002/126] One UI, two power levels: static site discovers a local runtime and goes live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arena/deck pages already degrade gracefully on a static host, but silently: relative /api fetches 404 and the page stays in demo mode even when a live roborun is one port away. runtime-base.js (loaded first by both pages) wraps fetch: /api calls resolve same-origin first, then probe http://127.0.0.1:8765. A badge shows the mode; in demo mode it keeps probing, so starting roborun upgrades the open page to the live cockpit with one click. Server side: Access-Control-Allow-Origin on all responses (deduped out of do_OPTIONS — doubled CORS headers are rejected by browsers) plus Access-Control-Allow-Private-Network for Chrome PNA preflights. --- roborun/server.py | 9 +++- roborun/web/arena.html | 1 + roborun/web/deck.html | 1 + roborun/web/runtime-base.js | 94 +++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 roborun/web/runtime-base.js diff --git a/roborun/server.py b/roborun/server.py index 14e5f7f..97f87fc 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -55,6 +55,9 @@ def log_message(self, fmt: str, *args: Any) -> None: def end_headers(self) -> None: self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + # the static site (GitHub Pages, python -m http.server -d site) probes + # this server cross-origin and upgrades itself to the live cockpit + self.send_header("Access-Control-Allow-Origin", "*") super().end_headers() def do_GET(self) -> None: @@ -88,9 +91,13 @@ def do_GET(self) -> None: def do_OPTIONS(self) -> None: self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") + # Allow-Origin comes from end_headers — sending it here too would + # duplicate the header, which browsers reject outright self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") + # Chrome Private Network Access: lets a public https site (the hosted + # demo) reach this server on 127.0.0.1 once PNA enforcement lands + self.send_header("Access-Control-Allow-Private-Network", "true") self.end_headers() def do_POST(self) -> None: diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 0baa50d..e4e89da 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -1,6 +1,7 @@ + RoboRun Arena diff --git a/roborun/web/deck.html b/roborun/web/deck.html index e481aeb..46baf55 100644 --- a/roborun/web/deck.html +++ b/roborun/web/deck.html @@ -1,6 +1,7 @@ + RoboRun Flight Deck: ROS robot control, live vision, tamper-evident black box diff --git a/roborun/web/runtime-base.js b/roborun/web/runtime-base.js new file mode 100644 index 0000000..e263bf5 --- /dev/null +++ b/roborun/web/runtime-base.js @@ -0,0 +1,94 @@ +/* Runtime discovery: one UI, two power levels. + * + * Served by roborun itself, /api/* is same-origin and everything just works. + * Served from a static host (GitHub Pages, `python -m http.server -d site`), + * the same page probes for a local roborun runtime on 127.0.0.1:8765 and, if + * one answers, routes every /api call to it — the demo page becomes the live + * cockpit. No runtime anywhere → the in-browser arena (Pyodide) carries on. + * + * Loaded before arena.js/deck.js; wraps window.fetch so existing relative + * "/api/…" calls need no changes. Queues /api calls until probing settles. + */ +(() => { + const LOCAL = "http://127.0.0.1:8765"; + const state = { base: "", live: false, remote: false }; + window.ROBORUN_RUNTIME = state; + + const origFetch = window.fetch.bind(window); + + const probe = async (base) => { + try { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 1500); + const r = await origFetch(base + "/api/health", { signal: ctl.signal }); + clearTimeout(t); + return r.ok; + } catch { + return false; + } + }; + + const resolved = (async () => { + if (await probe("")) { + state.live = true; + } else if (location.port !== "8765" && await probe(LOCAL)) { + state.base = LOCAL; + state.live = state.remote = true; + } + document.dispatchEvent(new CustomEvent("roborun-runtime", { detail: state })); + return state; + })(); + + window.fetch = async (input, init) => { + if (typeof input === "string" && input.startsWith("/api")) { + await resolved; + return origFetch(state.base + input, init); + } + return origFetch(input, init); + }; + + // ── status badge ───────────────────────────────────────────────────── + const badge = () => { + const el = document.createElement("div"); + el.id = "runtime-badge"; + el.style.cssText = + "position:fixed;right:12px;bottom:12px;z-index:9999;padding:4px 10px;" + + "border-radius:12px;font:11px/1.6 ui-monospace,Menlo,monospace;" + + "background:#11161bcc;border:1px solid #2a333d;color:#8a96a3;" + + "backdrop-filter:blur(4px);cursor:default;user-select:none;"; + document.body.appendChild(el); + + const render = () => { + if (state.live) { + el.style.color = "#00d47e"; + el.style.borderColor = "#00d47e55"; + el.textContent = state.remote ? "● live — local runtime :8765" : "● live"; + el.style.cursor = "default"; + el.onclick = null; + } else { + el.style.color = "#8a96a3"; + el.textContent = "○ demo — run `roborun` to go live"; + } + }; + + resolved.then(render); + + // demo mode: keep listening so starting `roborun` upgrades the page + const recheck = async () => { + if (state.live) return; + if (await probe(LOCAL)) { + el.style.color = "#00d47e"; + el.style.borderColor = "#00d47e55"; + el.style.cursor = "pointer"; + el.textContent = "● runtime found — click to go live"; + el.onclick = () => location.reload(); + } else { + setTimeout(recheck, 5000); + } + }; + resolved.then(() => { if (!state.live) setTimeout(recheck, 5000); }); + }; + + if (document.body) badge(); + else document.addEventListener("DOMContentLoaded", badge); +})(); From 66466d356951e8e2a929e86389422a695539518c Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 18:56:50 -0400 Subject: [PATCH 003/126] Arena robot mode: a connected robot replaces the sim, same page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole thesis is switch from sim to robot without changes — so the arena, not a separate deck, is what a connected robot looks like. When /api/ros/health reports a robot, the same arena page enters robot mode: - pose/heading/altitude come from the telemetry handle (SIM_SPEC contract) and drive the same bot body; the level hides, the accumulated lidar cloud is the map, the minimap and telemetry panels read as before - an EYES panel shows /api/camera/stream — the same pixels robot.see() runs YOLO on - WASD publishes real cmd_vel through /api/ros/move (now with linear_z for drones); behaviors keep running server-side, untouched - pushState is gated off: feeding the arena backend while a robot is connected would flip get_arena().is_active() and silently reroute robot.see()/move() from hardware to the browser sim Plus two host-fallback fixes with the same root cause: _get_ros_client and RosTelemetry._try_subscribe demanded a profile robotIp and went dead without one, even while a live connection existed — both now ride the already-connected client. This was why robot_type never resolved (and why drone cmd_vel fell back to /cmd_vel). --- roborun/ros_telemetry.py | 13 ++--- roborun/routes/ros.py | 11 +++- roborun/web/arena.js | 106 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/roborun/ros_telemetry.py b/roborun/ros_telemetry.py index 90c98a4..f9e93a8 100644 --- a/roborun/ros_telemetry.py +++ b/roborun/ros_telemetry.py @@ -148,17 +148,18 @@ def _try_subscribe(self, bus: Any, fallback_host: str | None) -> None: except Exception: pass - if not host: - return - - client = get_client(host, auto_connect=False) + # no configured host: ride whatever connection `roborun connect` / + # boot already established rather than demanding a profile entry + client = (get_client(host, auto_connect=False) if host + else get_client(auto_connect=False)) if not client or not client.is_connected: self._subscribed_topics.clear() return - if self._last_host != host: + key = host or "active-client" + if self._last_host != key: self._subscribed_topics.clear() - self._last_host = host + self._last_host = key self._subscribe_client(bus, client) diff --git a/roborun/routes/ros.py b/roborun/routes/ros.py index 1dde1c4..da32b45 100644 --- a/roborun/routes/ros.py +++ b/roborun/routes/ros.py @@ -6,10 +6,15 @@ def _get_ros_client(host: str | None = None): + from roborun.rosbridge import get_client host = host or load_profile().get("robotIp", "") if not host: + # no profile entry, but `roborun connect` / boot may already hold a + # live connection — use it rather than demanding configuration + client = get_client(auto_connect=False) + if client and client.is_connected: + return client raise ApiError(400, "No robot IP configured") - from roborun.rosbridge import get_client client = get_client(host) if not client: raise ApiError(503, "Not connected to rosbridge") @@ -26,6 +31,7 @@ def cloud(h): "ok": True, "points": b.cloud_points(), "pose": b.handle_pose(), + "lidar": b.handle_lidar(), "robot_type": b.robot_type.value if b.robot_type else None, }) @@ -230,11 +236,12 @@ def ros_depth(h, payload): def ros_move(h, payload): linear_x = float(payload.get("linear_x", 0.0)) linear_y = float(payload.get("linear_y", 0.0)) + linear_z = float(payload.get("linear_z", 0.0)) # climb, for drones angular_z = float(payload.get("angular_z", 0.0)) topic = str(payload.get("topic", "/cmd_vel")) try: client = _get_ros_client() - client.move(linear_x, linear_y, angular_z, topic) + client.move(linear_x, linear_y, angular_z, topic, linear_z=linear_z) from roborun.events import emit emit("ros", "rosbridge", f"cmd_vel linear={linear_x:.2f} angular={angular_z:.2f}", diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 73175cf..91c4f94 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1250,6 +1250,14 @@ async function api(path, body) { let MODE = "detect"; // "server" | "wasm" let wasmRT = null, wasmLoading = false; async function detectMode() { + // a connected robot wins: the arena renders *its* reality, not the sim's + try { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 1500); + const r = await fetch("/api/ros/health", { signal: ctl.signal }); + clearTimeout(t); + if (r.ok && (await r.json()).connected) { enterRobotMode(); return; } + } catch {} try { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 1500); @@ -1261,6 +1269,76 @@ async function detectMode() { document.body.classList.add("wasm-mode"); bootWasm(); } + +/* ── robot mode: same arena, the world is the robot's telemetry ───────── + Pose, lidar cloud, and detections come from the connected robot via the + runtime (SIM_SPEC handle contract: {x, z, heading} + 36-sector scan). + The level disappears, the accumulated point cloud *is* the map, the + EYES panel shows the camera frames robot.see() runs YOLO on, and WASD + publishes real cmd_vel. Sim → robot without changing pages. */ +const RT_BASE = () => (window.ROBORUN_RUNTIME && window.ROBORUN_RUNTIME.base) || ""; +let robotCloudSeen = 0, lastMoveSent = 0; +function enterRobotMode() { + MODE = "robot"; + document.body.classList.add("robot-mode"); + if (levelGroup) levelGroup.visible = false; + cloudOn = true; cloud.visible = true; + const grid = new THREE.GridHelper(80, 80, 0x223344, 0x141d26); + grid.position.y = 0.01; + scene.add(grid); + const eyes = document.createElement("div"); + eyes.id = "robotEyes"; + eyes.style.cssText = + "position:fixed;right:12px;top:12px;z-index:999;width:320px;" + + "border:1px solid #2a333d;border-radius:6px;overflow:hidden;background:#000;"; + eyes.innerHTML = + '
EYES — live robot camera
' + + ``; + document.body.appendChild(eyes); + pollRobot(); +} +async function pollRobot() { + try { + const r = await (await fetch("/api/ros/cloud")).json(); + if (r.pose) { + bot.pos.x = r.pose.x; + bot.pos.z = r.pose.z; + bot.heading = r.pose.heading || 0; + if (r.pose.y !== undefined && r.pose.y !== null) bot.alt = r.pose.y; + if (bot.group) { + bot.group.position.set(bot.pos.x, bot.type === "drone" ? bot.alt : 0, bot.pos.z); + bot.group.rotation.y = bot.heading; + } + } + if (r.robot_type === "drone" && bot.type !== "drone") { + bot.type = "drone"; + buildBody("drone"); + } + const pts = r.points || []; + if (pts.length < robotCloudSeen) robotCloudSeen = 0; // server restarted + for (let i = robotCloudSeen; i < pts.length; i++) + cloudAdd(pts[i][0], 0.12, pts[i][1], pts[i][2]); + robotCloudSeen = pts.length; + cloudCommit(); + if (r.lidar && r.lidar.length) { + lastLidar = r.lidar; + integrateLidar(lastLidar); + } + drawMap(); + updateTelemetry(); + } catch {} + setTimeout(pollRobot, 150); +} +function sendRobotMove(cmd) { + const now = performance.now(); + if (now - lastMoveSent < 120) return; + lastMoveSent = now; + api("/api/ros/move", { + linear_x: cmd.forward || 0, linear_y: cmd.strafe || 0, + linear_z: cmd.climb || 0, angular_z: cmd.turn || 0, + }).catch(() => {}); +} async function bootWasm() { if (wasmRT || wasmLoading) return; wasmLoading = true; @@ -1284,7 +1362,7 @@ let serverSightings = []; async function pollSightings() { if (MODE === "wasm" && wasmRT) { try { serverSightings = wasmRT.sightings() || []; } catch {} - } else if (linked) { + } else if (linked || MODE === "robot") { try { const r = await (await fetch("/api/sightings")).json(); serverSightings = r.sightings || []; @@ -1294,6 +1372,12 @@ async function pollSightings() { } async function pollCmd() { const el = document.getElementById("link"); + if (MODE === "robot") { + el.textContent = "robot: live — behaviors run on the runtime"; + el.className = "link on"; + setTimeout(pollCmd, 2000); + return; + } if (MODE === "wasm") { // cmd arrives synchronously from each tick in pushState el.textContent = wasmRT ? "policy: in-browser python" @@ -1329,6 +1413,10 @@ function currentState() { }; } async function pushState() { + if (MODE === "robot") { // the robot is the source of truth; + setTimeout(pushState, 1000); // feeding the arena backend would + return; // reroute see()/move() to the sim + } if (MODE === "wasm" && wasmRT) { try { const r = wasmRT.tick(currentState()); @@ -1888,15 +1976,21 @@ function frame(now) { adaptQuality(dt); renderer.shadowMap.needsUpdate = true; const cmd = keyboardCmd() || serverCmd; - updateMovers(dt); - updateBody(dt, cmd); - syncProps(); - tickChamber(dt, serverAnswer); + if (MODE === "robot") { + const k = keyboardCmd(); // WASD drives the real robot + if (k) sendRobotMove(k); + // pose/cloud/lidar arrive from telemetry in pollRobot() + } else { + updateMovers(dt); + updateBody(dt, cmd); + syncProps(); + tickChamber(dt, serverAnswer); + } odo += prevPos.distanceTo(bot.pos); prevPos.copy(bot.pos); senseTick += dt; - if (senseTick > 0.12) { + if (MODE !== "robot" && senseTick > 0.12) { senseTick = 0; lastLidar = senseLidar(); integrateLidar(lastLidar); From 065dcae0f1ead476a3de23ece0a096a7721b5eb3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 19:12:33 -0400 Subject: [PATCH 004/126] Sources are first-class: per-source camera streams, LAN robot discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera stream was last-writer-wins: webcam and robot camera both wrote /tmp/roborun_frame.jpg, so the EYES panel showed your desk while claiming to be the robot. Each pipeline now writes its own file and /api/camera/stream takes ?source=robot|webcam|auto (auto: fresh robot frames outrank the webcam). New sources layer answers "what can see and what can move, right now": - GET /api/sources — webcam on/off, connected robot + camera state, and rosbridges discovered on the local /24 (plain TCP probe of :9090, cached 60s); POST /api/sources/scan forces a rescan - arena EYES panel gets a source picker (robot camera / webcam) - in sim modes the arena shows a chip for any rosbridge found on the network — one click connects and reloads into robot mode. A robot on your wifi is a source, not a config step. --- roborun/ros_camera.py | 4 +- roborun/routes/sources.py | 16 ++++++ roborun/server.py | 31 +++++++++--- roborun/sources.py | 104 ++++++++++++++++++++++++++++++++++++++ roborun/web/arena.js | 52 +++++++++++++++++-- 5 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 roborun/routes/sources.py create mode 100644 roborun/sources.py diff --git a/roborun/ros_camera.py b/roborun/ros_camera.py index 927c4fa..1a7c907 100644 --- a/roborun/ros_camera.py +++ b/roborun/ros_camera.py @@ -6,7 +6,7 @@ front of the *robot*" on every backend: arena → arena ground truth, connected robot → this pipeline, neither → local webcam. -Frames also land at /tmp/roborun_frame.jpg, so the deck's camera panel and +Frames also land at /tmp/roborun_robot_frame.jpg, so the deck's camera panel and robot.ask(image=True) show the robot's view, not your desk. """ from __future__ import annotations @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any -FRAME_PATH = Path("/tmp/roborun_frame.jpg") +FRAME_PATH = Path("/tmp/roborun_robot_frame.jpg") _FRESH = 2.0 _DETECT_HZ = 5.0 diff --git a/roborun/routes/sources.py b/roborun/routes/sources.py new file mode 100644 index 0000000..cc0a030 --- /dev/null +++ b/roborun/routes/sources.py @@ -0,0 +1,16 @@ +"""Source routes — what can see/move right now, and what's on the network.""" +from __future__ import annotations + +from roborun.routes import get, post, send_json + + +@get("/api/sources") +def sources(h): + from roborun.sources import inventory + send_json(h, 200, inventory()) + + +@post("/api/sources/scan") +def sources_scan(h, payload): + from roborun.sources import network_scan + send_json(h, 200, {"ok": True, **network_scan(force=True)}) diff --git a/roborun/server.py b/roborun/server.py index 97f87fc..efa406c 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -23,13 +23,28 @@ PORT = int(os.environ.get("ROBORUN_PORT", "8765")) STATE_ROOT = ROOT / ".roborun" -_FRAME_PATHS = [ - Path("/tmp/roborun_frame.jpg"), - Path("/tmp/roborun_camera.jpg"), -] +# each pipeline writes its own file; the stream picks by ?source= +_SOURCE_FRAMES = { + "robot": [Path("/tmp/roborun_robot_frame.jpg")], + "webcam": [Path("/tmp/roborun_frame.jpg"), Path("/tmp/roborun_camera.jpg")], +} + + +def _stream_paths(source: str) -> list: + if source in _SOURCE_FRAMES: + return _SOURCE_FRAMES[source] + # auto: a robot camera producing fresh frames outranks the webcam + robot = _SOURCE_FRAMES["robot"][0] + try: + if time.time() - robot.stat().st_mtime < 2.0: + return [robot] + except OSError: + pass + return _SOURCE_FRAMES["webcam"] # Import route modules — registering all @get/@post handlers import roborun.routes.dashboard # noqa: F401 +import roborun.routes.sources # noqa: F401 import roborun.routes.fleet # noqa: F401 import roborun.routes.tasks # noqa: F401 import roborun.routes.webcam # noqa: F401 @@ -75,7 +90,9 @@ def do_GET(self) -> None: # MJPEG camera stream if path_only == "/api/camera/stream": - self._mjpeg_stream() + from urllib.parse import parse_qs, urlparse + q = parse_qs(urlparse(self.path).query) + self._mjpeg_stream((q.get("source") or ["auto"])[0]) return # Route registry @@ -155,7 +172,7 @@ def _event_stream(self) -> None: finally: unsubscribe(q) - def _mjpeg_stream(self) -> None: + def _mjpeg_stream(self, source: str = "auto") -> None: self.send_response(200) self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") self.send_header("Cache-Control", "no-cache") @@ -169,7 +186,7 @@ def _mjpeg_stream(self) -> None: except Exception: pass while time.monotonic() - started < 300: - for p in _FRAME_PATHS: + for p in _stream_paths(source): if p.exists(): mtime = p.stat().st_mtime if mtime != last_mtime: diff --git a/roborun/sources.py b/roborun/sources.py new file mode 100644 index 0000000..98b0aee --- /dev/null +++ b/roborun/sources.py @@ -0,0 +1,104 @@ +"""Source inventory: what can see and what can move, right now. + +One place that answers "what's available?" — the webcam, a connected +robot's camera, and rosbridge servers discovered on the local network — +so UIs offer sources instead of making the user remember ports. The LAN +scan is a plain TCP probe of :9090 across the machine's /24: cheap, +parallel, and exactly what `roborun connect ` would need anyway. +""" +from __future__ import annotations + +import socket +import threading +import time +from typing import Any + +ROSBRIDGE_PORT = 9090 +_SCAN_TTL = 60.0 # results stay fresh this long +_scan_lock = threading.Lock() +_scan: dict[str, Any] = {"scanning": False, "found": [], "scanned_at": 0.0} + + +def _local_subnet() -> list[str]: + """Hosts on this machine's /24 (best effort, no extra deps).""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) # no traffic sent; just routes + ip = s.getsockname()[0] + s.close() + except Exception: + return [] + base = ip.rsplit(".", 1)[0] + return [f"{base}.{i}" for i in range(1, 255) if f"{base}.{i}" != ip] + + +def _probe(host: str, port: int = ROSBRIDGE_PORT, timeout: float = 0.6) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except Exception: + return False + + +def _scan_worker() -> None: + found: list[dict] = [] + if _probe("127.0.0.1"): + found.append({"host": "127.0.0.1", "port": ROSBRIDGE_PORT, "local": True}) + hosts = _local_subnet() + sem = threading.Semaphore(64) + lock = threading.Lock() + + def check(h: str) -> None: + with sem: + if _probe(h): + with lock: + found.append({"host": h, "port": ROSBRIDGE_PORT, "local": False}) + + threads = [threading.Thread(target=check, args=(h,), daemon=True) for h in hosts] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5.0) + with _scan_lock: + _scan.update(scanning=False, found=found, scanned_at=time.time()) + + +def network_scan(force: bool = False) -> dict[str, Any]: + """Cached LAN rosbridge inventory; kicks a background rescan when stale.""" + with _scan_lock: + stale = time.time() - _scan["scanned_at"] > _SCAN_TTL + if (force or stale) and not _scan["scanning"]: + _scan["scanning"] = True + threading.Thread(target=_scan_worker, daemon=True).start() + return dict(_scan) + + +def inventory() -> dict[str, Any]: + """Everything a source picker needs, without side effects: peeking + must never start a pipeline or open the camera.""" + from roborun.routes import _singletons + + wc = _singletons._webcam # peek — do not instantiate + webcam = {"available": True, "on": bool(wc and wc.is_running)} + + robot: dict[str, Any] = {"connected": False} + try: + from roborun.rosbridge import get_client + client = get_client(auto_connect=False) + if client and client.is_connected: + from roborun.ros_camera import get_ros_camera + from roborun.ros_telemetry import get_bridge + cam = get_ros_camera().state() + b = get_bridge() + robot = { + "connected": True, + "host": client.health.get("host"), + "type": b.robot_type.value if b.robot_type else None, + "camera_topic": cam.get("topic"), + "camera_active": cam.get("active", False), + } + except Exception: + pass + + return {"ok": True, "webcam": webcam, "robot": robot, + "network": network_scan()} diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 91c4f94..eb4eecd 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1292,12 +1292,56 @@ function enterRobotMode() { "position:fixed;right:12px;top:12px;z-index:999;width:320px;" + "border:1px solid #2a333d;border-radius:6px;overflow:hidden;background:#000;"; eyes.innerHTML = - '
EYES — live robot camera
' + - ``; + '
EYES' + + '
' + + ``; document.body.appendChild(eyes); + document.getElementById("eyesSrc").addEventListener("change", (e) => { + document.getElementById("eyesImg").src = + `${RT_BASE()}/api/camera/stream?source=${e.target.value}&t=${Date.now()}`; + }); pollRobot(); } + +/* ── network robots: surface rosbridges found on the LAN ───────────────── + In sim modes the runtime scans the local /24 for :9090; anything found + appears as a chip — one click connects and the page reloads straight + into robot mode. A robot on your wifi is a source, not a config step. */ +async function pollNetworkRobots() { + if (MODE === "robot") return; + try { + const r = await (await fetch("/api/sources")).json(); + const found = (r.network && r.network.found) || []; + let chip = document.getElementById("netRobots"); + if (found.length && !r.robot.connected) { + if (!chip) { + chip = document.createElement("div"); + chip.id = "netRobots"; + chip.style.cssText = + "position:fixed;right:12px;top:12px;z-index:999;padding:5px 12px;" + + "border-radius:12px;font:11px/1.6 ui-monospace,Menlo,monospace;" + + "background:#11161bcc;border:1px solid #00d47e55;color:#00d47e;" + + "cursor:pointer;user-select:none;"; + document.body.appendChild(chip); + } + const r0 = found.find((f) => f.local) || found[0]; + chip.textContent = `◈ robot on network — ${r0.host}:${r0.port} · click to connect`; + chip.onclick = async () => { + chip.textContent = "connecting…"; + const res = await api("/api/ros/connect", { host: r0.host, port: r0.port }); + if (res.ok) location.reload(); + else chip.textContent = `connect failed: ${res.error || "?"}`; + }; + } else if (chip) { + chip.remove(); + } + } catch {} + setTimeout(pollNetworkRobots, 10000); +} async function pollRobot() { try { const r = await (await fetch("/api/ros/cloud")).json(); @@ -2033,7 +2077,7 @@ function frame(now) { await initPhysics(); // rapier WASM, once per page loadLevel(0); -detectMode(); pollCmd(); pushState(); pollSightings(); renderRuns(); +detectMode().then(() => pollNetworkRobots()); pollCmd(); pushState(); pollSightings(); renderRuns(); requestAnimationFrame(frame); /* harness hook — scripts/e2e_arena.mjs drives the page without the UI */ From d7e0dbd6919a8f41de546347eea1b97a5c931f3d Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 19:20:53 -0400 Subject: [PATCH 005/126] Robot mode is segmented: sim furniture out, robot interfaces in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connected robot deserves a robot cockpit, not a game with a robot in it. The screenshot version showed DOG—SANDBOX missions, practice RUNS, sim crates in the main view, and a sandbox policy editor one click away from commanding live hardware — the exact path by which player_policy (forward=0.8 at 10 Hz) flew the test drone to 53 m and 45 m off the map. In robot mode: - MISSION/LEVELS/RUNS panels and their toolbar buttons hide (CSS via body.robot-mode); loadLevel can no longer resurrect the sim level - the POLICY panel becomes the robot's behavior editor: it loads the source of the behavior actually running (new POST /api/behaviors/read, stem-only, no paths), RUN hot-reloads that file, STOP disables it - deploying to hardware is deliberate: RUN asks for confirmation and names the file; the LLM mission compiler gets a be-conservative context instead of the level brief - footer says what WASD really does now: drives the real robot - room/practice telemetry hides; main camera defaults to chase --- roborun/routes/behaviors.py | 15 +++++++++++ roborun/web/arena.html | 6 +++++ roborun/web/arena.js | 53 ++++++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/roborun/routes/behaviors.py b/roborun/routes/behaviors.py index 4dad9fa..009938c 100644 --- a/roborun/routes/behaviors.py +++ b/roborun/routes/behaviors.py @@ -29,6 +29,21 @@ def disable(h, payload): {"ok": ok} if ok else {"ok": False, "error": f"no behavior named {name!r}"}) +@post("/api/behaviors/read") +def read_source(h, payload): + """Editor loads a running behavior's source. Stem only — no paths.""" + from pathlib import Path + name = str(payload.get("name", "")).strip() + if not name or "/" in name or "\\" in name or ".." in name: + send_json(h, 400, {"ok": False, "error": "bad name"}) + return + p = Path("behaviors") / f"{name}.py" + if not p.is_file(): + send_json(h, 404, {"ok": False, "error": f"{name} not found"}) + return + send_json(h, 200, {"ok": True, "name": name, "source": p.read_text()}) + + @post("/api/behaviors/write") def write(h, payload): """The arena code panel writes through here — same validation as MCP.""" diff --git a/roborun/web/arena.html b/roborun/web/arena.html index e4e89da..4fd452c 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -200,6 +200,12 @@ .vstate.good { color: #00d47e; } .vstate.bad { color: #d84a4a; } .run-row video { width: 100%; margin-top: 6px; border-radius: 4px; } +/* robot mode: sims are sims, robots are robots — hide arena-game UI */ +body.robot-mode #p-brief, body.robot-mode #p-runs, +body.robot-mode #btnLevels, +body.robot-mode button[data-panel="p-brief"], +body.robot-mode button[data-panel="p-runs"], +body.robot-mode #teleRoom { display: none !important; } diff --git a/roborun/web/arena.js b/roborun/web/arena.js index eb4eecd..a9d24e2 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -915,6 +915,7 @@ function loadLevel(i) { else if (w.type === "rings") setChips( w.order.map((id) => ({ id: `r${id}`, label: `ring ${id + 1}`, done: false }))); postEvent("arena", `level loaded: ${LV.name}`, { robot: LV.robot }); + if (MODE === "robot" && levelGroup) levelGroup.visible = false; } levelSel.addEventListener("change", () => loadLevel(+levelSel.value)); @@ -1304,8 +1305,33 @@ function enterRobotMode() { document.getElementById("eyesImg").src = `${RT_BASE()}/api/camera/stream?source=${e.target.value}&t=${Date.now()}`; }); + // the policy panel edits what is actually running on the robot — no + // sandbox starter, no silent deploys + const phead = document.querySelector("#p-policy .p-head span"); + if (phead) phead.textContent = "— live on the connected robot · RUN hot-reloads it"; + const keysEl = document.getElementById("keys"); + if (keysEl) keysEl.innerHTML = "drag headers · resize corners · ⌘⏎ deploy · " + + 'WASD drives the real robot' + + ' · deck'; + if (mainCamSel) mainCamSel.value = "chase"; + loadRobotBehavior(); pollRobot(); } +let robotPolicyName = "robot_policy"; +async function loadRobotBehavior() { + try { + const r = await (await fetch("/api/behaviors")).json(); + const live = (r.behaviors || []).find((b) => + b.enabled && !["fix_camera", "heartbeat", "player_policy"].includes(b.name)); + if (!live) { policyStatus("no behavior running — write one and RUN", ""); return; } + robotPolicyName = live.name; + const src = await api("/api/behaviors/read", { name: live.name }); + if (src.ok) { + setCode(src.source); + policyStatus(`editing ${live.name} — live on the robot, RUN applies`, "ok"); + } + } catch {} +} /* ── network robots: surface rosbridges found on the LAN ───────────────── In sim modes the runtime scans the local /24 for :9090; anything found @@ -1691,17 +1717,29 @@ document.getElementById("btnRun").addEventListener("click", async () => { if (!source.includes("@behavior")) { // words, not code: compile the mission into a policy first policyStatus("✨ compiling mission via LLM… (~10s)", ""); - const c = await api("/api/behaviors/compile", - { mission: source, context: LV.title + ": " + LV.brief }); + const ctx = MODE === "robot" + ? "a real connected robot — be conservative with speeds" + : LV.title + ": " + LV.brief; + const c = await api("/api/behaviors/compile", { mission: source, context: ctx }); if (!c.ok) { policyStatus(c.error, "err"); return; } source = c.source; setCode(source); // language in, code out — inspect/edit it } + // sandbox writes player_policy; robot mode edits the robot's own + // behavior file, and never without saying so + const name = MODE === "robot" ? robotPolicyName : "player_policy"; + if (MODE === "robot" && + !confirm(`Deploy "${name}" to the CONNECTED ROBOT?\nIt hot-reloads and starts moving hardware immediately.`)) { + policyStatus("deploy cancelled", ""); + return; + } policyStatus("saving…", ""); - const w = await api("/api/behaviors/write", { name: "player_policy", source }); + const w = await api("/api/behaviors/write", { name, source }); if (!w.ok) { policyStatus(w.error, "err"); return; } - await api("/api/behaviors/enable", { name: "player_policy" }); - policyStatus("running — hot reload applies edits on every RUN", "ok"); + await api("/api/behaviors/enable", { name }); + policyStatus(MODE === "robot" + ? `live on the robot as ${name} — RUN applies edits` + : "running — hot reload applies edits on every RUN", "ok"); } catch { policyStatus("no server — run `roborun` first", "err"); } }); document.getElementById("btnStop").addEventListener("click", async () => { @@ -1711,8 +1749,9 @@ document.getElementById("btnStop").addEventListener("click", async () => { return; } try { - await api("/api/behaviors/disable", { name: "player_policy" }); - policyStatus("stopped", ""); + const name = MODE === "robot" ? robotPolicyName : "player_policy"; + await api("/api/behaviors/disable", { name }); + policyStatus(MODE === "robot" ? `${name} stopped — robot holds` : "stopped", ""); } catch {} }); From a4b581e37fa171d7d6db9383130af592bee9361e Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 22:21:04 -0400 Subject: [PATCH 006/126] Robot cockpit: a beautiful ground-control station, the camera is the hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arena was still a game with a robot crudely piped in — a low-poly dog floating in a void, the real camera shrunk to a corner, sim crates in view, the onboarding splash ('pick a robot, pick a task') greeting a live drone. A connected robot now gets a purpose-built cockpit instead: - the robot camera fills the stage (full-bleed, cinematic vignette) with live YOLO detection boxes overlaid (new /api/robot/detections, normalized) - a glass identity bar: type glyph, LIVE pulse, host, and ALT/SPEED/HDG telemetry chips; OSD reticle + POS/ALT/HDG readouts over the feed - a tactical minimap (range rings, trail, heading wedge; lidar when present) - POLICY slides in to edit the *running* behavior; DEPLOY is confirmed - an event ticker of the robot's live decisions - the game panels, 3D arena, toolbar, and splash are fully suppressed in robot mode; the splash is gated so it never shows over a robot Camera served as single JPEG frames (/api/camera/frame) polled by the client — deterministic, unlike an MJPEG that half-paints. Telemetry hardened against flaky rosapi discovery: /rosapi/topics times out on this setup, which left type=webcam_only and no pose. Now when discovery returns empty, trust the type roborun connect saved and subscribe to the standard topics blind (a subscribe to a not-yet-seen topic is harmless and flows when it appears). --- roborun/ros_camera.py | 21 +++ roborun/ros_telemetry.py | 41 +++++- roborun/routes/sources.py | 7 + roborun/server.py | 25 ++++ roborun/web/arena.html | 199 +++++++++++++++++++++++++++- roborun/web/arena.js | 272 +++++++++++++++++++++++++------------- 6 files changed, 463 insertions(+), 102 deletions(-) diff --git a/roborun/ros_camera.py b/roborun/ros_camera.py index 1a7c907..31ffaa8 100644 --- a/roborun/ros_camera.py +++ b/roborun/ros_camera.py @@ -136,6 +136,27 @@ def snapshot(self): with self._lock: return self._frame + def detections_normalized(self) -> dict[str, Any]: + """Detections as fractions of the frame, for a camera overlay that + scales with however the HUD sizes the feed.""" + with self._lock: + frame = self._frame + dets = list(self._detections) + if frame is None: + return {"w": 0, "h": 0, "detections": []} + h, w = frame.shape[:2] + out = [] + for d in dets: + x1, y1, x2, y2 = d.get("bbox", (0, 0, 0, 0)) + out.append({ + "label": d.get("label", "?"), + "conf": d.get("confidence", 0.0), + "track_id": d.get("track_id"), + "x": x1 / w, "y": y1 / h, + "w": (x2 - x1) / w, "h": (y2 - y1) / h, + }) + return {"w": w, "h": h, "detections": out} + def state(self) -> dict[str, Any]: with self._lock: # inline the freshness check: is_active() takes this same diff --git a/roborun/ros_telemetry.py b/roborun/ros_telemetry.py index f9e93a8..ad88ac3 100644 --- a/roborun/ros_telemetry.py +++ b/roborun/ros_telemetry.py @@ -27,6 +27,16 @@ ("/tf", "tf2_msgs/TFMessage"), ] + +def _saved_robot_type() -> str | None: + """Type `roborun connect` wrote to ~/.roborun/robot.json, if any.""" + import json + from pathlib import Path + try: + return json.loads((Path.home() / ".roborun" / "robot.json").read_text()).get("type") + except Exception: + return None + _instance: RosTelemetryBridge | None = None _lock = threading.Lock() @@ -171,14 +181,32 @@ def _subscribe_client(self, bus: Any, client: Any) -> None: try: available = client.list_topics(timeout=3.0) except Exception: - return + available = [] available_names = {t["topic"] for t in available} - # the robot's own topic map (mavros drones, etc.) extends the - # standard table — same handlers, the robot's topic names - from roborun.robot_types import detect_type, get_profile - self.robot_type = detect_type(ros_topics=sorted(available_names)) + # rosapi topic discovery is flaky over rosbridge (the /rosapi/topics + # service times out on some setups). When it comes back empty, don't + # go dark: trust the type `roborun connect` saved and subscribe to the + # candidate topics blind — a subscribe to a not-yet-seen topic is + # harmless and starts flowing the moment the topic appears. + discovery_ok = bool(available_names) + + from roborun.robot_types import detect_type, get_profile, RobotType + if discovery_ok: + self.robot_type = detect_type(ros_topics=sorted(available_names)) + elif self.robot_type is None: + saved = None + try: + from roborun.routes.dashboard import load_profile + saved = (load_profile().get("robotType") + or _saved_robot_type()) + except Exception: + pass + try: + self.robot_type = RobotType(saved) if saved else None + except Exception: + self.robot_type = None type_topics = (get_profile(self.robot_type) or {}).get("ros_topics", {}) self.cmd_vel_topic = type_topics.get("cmd_vel", "/cmd_vel") _TYPE_MSG = {"odom": "nav_msgs/Odometry", @@ -193,7 +221,8 @@ def _subscribe_client(self, bus: Any, client: Any) -> None: for topic, msg_type in table: if topic in self._subscribed_topics: continue - if topic not in available_names: + # gate on availability only when discovery actually worked + if discovery_ok and topic not in available_names: continue handler = self._make_handler(topic, msg_type, bus) diff --git a/roborun/routes/sources.py b/roborun/routes/sources.py index cc0a030..8e05142 100644 --- a/roborun/routes/sources.py +++ b/roborun/routes/sources.py @@ -14,3 +14,10 @@ def sources(h): def sources_scan(h, payload): from roborun.sources import network_scan send_json(h, 200, {"ok": True, **network_scan(force=True)}) + + +@get("/api/robot/detections") +def robot_detections(h): + """Normalized YOLO boxes from the robot camera, for the cockpit overlay.""" + from roborun.ros_camera import get_ros_camera + send_json(h, 200, {"ok": True, **get_ros_camera().detections_normalized()}) diff --git a/roborun/server.py b/roborun/server.py index efa406c..aab5c48 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -95,6 +95,13 @@ def do_GET(self) -> None: self._mjpeg_stream((q.get("source") or ["auto"])[0]) return + # single JPEG frame — robust feed for clients that poll img.src + if path_only == "/api/camera/frame": + from urllib.parse import parse_qs, urlparse + q = parse_qs(urlparse(self.path).query) + self._camera_frame((q.get("source") or ["auto"])[0]) + return + # Route registry if dispatch_get(self.path, self): return @@ -172,6 +179,24 @@ def _event_stream(self) -> None: finally: unsubscribe(q) + def _camera_frame(self, source: str = "auto") -> None: + for p in _stream_paths(source): + try: + data = p.read_bytes() + except OSError: + continue + self.send_response(200) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + self.send_response(503) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + def _mjpeg_stream(self, source: str = "auto") -> None: self.send_response(200) self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 4fd452c..9f9802c 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -200,16 +200,203 @@ .vstate.good { color: #00d47e; } .vstate.bad { color: #d84a4a; } .run-row video { width: 100%; margin-top: 6px; border-radius: 4px; } -/* robot mode: sims are sims, robots are robots — hide arena-game UI */ -body.robot-mode #p-brief, body.robot-mode #p-runs, -body.robot-mode #btnLevels, -body.robot-mode button[data-panel="p-brief"], -body.robot-mode button[data-panel="p-runs"], -body.robot-mode #teleRoom { display: none !important; } +/* ════════════════════════════════════════════════════════════════════ + ROBOT COCKPIT — a connected robot gets a ground-control station, not a + game with a robot in it. The camera is the hero; everything else is a + glass HUD over it. Shown only in robot mode; the game panels + 3D arena + are fully suppressed. + ════════════════════════════════════════════════════════════════════ */ +body.robot-mode #toolbar, body.robot-mode #keys, body.robot-mode #toasts, +body.robot-mode .panel, body.robot-mode canvas.webgl, +body.robot-mode #start, body.robot-mode #win { display: none !important; } +body.robot-mode { background: #06080a; } + +#cockpit { position: fixed; inset: 0; z-index: 20; display: none; + flex-direction: column; } +body.robot-mode #cockpit { display: flex; } + +/* hero camera fills the stage */ +#ck-stage { position: absolute; inset: 0; overflow: hidden; background: + radial-gradient(120% 120% at 50% 0%, #0c1116 0%, #06080a 70%); } +#ck-cam { position: absolute; inset: 0; width: 100%; height: 100%; + object-fit: cover; filter: saturate(1.05) contrast(1.03); } +#ck-overlay { position: absolute; inset: 0; pointer-events: none; } +/* cinematic vignette so a flat sim render reads as a feed */ +#ck-stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; + background: radial-gradient(130% 100% at 50% 50%, transparent 58%, rgba(0,0,0,.55) 100%); + mix-blend-mode: multiply; } + +/* top identity bar */ +#ck-top { position: relative; z-index: 30; display: flex; align-items: center; + gap: 14px; margin: 14px 16px 0; padding: 9px 16px; + background: rgba(8,12,15,.72); backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(42,58,70,.5); border-radius: 13px; + box-shadow: 0 8px 30px rgba(0,0,0,.45); } +#ck-id { display: flex; align-items: center; gap: 9px; font-size: 13px; + letter-spacing: .12em; color: #d7e0e8; font-weight: 700; } +#ck-id .glyph { font-size: 15px; color: #00d47e; } +#ck-live { display: flex; align-items: center; gap: 6px; font-size: 10px; + letter-spacing: .16em; color: #00d47e; } +#ck-live .dot { width: 7px; height: 7px; border-radius: 50%; background: #00d47e; + box-shadow: 0 0 0 0 rgba(0,212,126,.6); animation: ckPulse 1.8s infinite; } +@keyframes ckPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(0,212,126,.55);} + 50% { box-shadow: 0 0 0 7px rgba(0,212,126,0);} } +#ck-where { color: #6b7b88; font-size: 11px; letter-spacing: .06em; } +#ck-tele { display: flex; gap: 7px; margin-left: auto; } +.ck-chip { display: flex; flex-direction: column; align-items: center; + min-width: 58px; padding: 3px 11px; border-radius: 8px; + background: rgba(16,22,28,.7); border: 1px solid rgba(42,58,70,.5); } +.ck-chip .k { font-size: 8.5px; letter-spacing: .18em; color: #5a6b78; } +.ck-chip .v { font-size: 15px; color: #00d47e; font-variant-numeric: tabular-nums; + line-height: 1.15; } +.ck-chip.warn .v { color: #e0a030; } +#ck-actions { display: flex; gap: 7px; align-items: center; } +.ck-btn { background: rgba(16,22,28,.8); color: #9fb0bd; border: 1px solid #2a3a46; + border-radius: 8px; font: inherit; font-size: 11px; letter-spacing: .08em; + padding: 6px 12px; cursor: pointer; transition: all .15s; } +.ck-btn:hover { color: #d7e0e8; border-color: #3a4d5a; } +.ck-btn.hot { color: #06080a; background: #00d47e; border-color: #00d47e; font-weight: 700; } +.ck-btn.hot:hover { background: #1ee08c; } +.ck-btn.danger { color: #e0a030; border-color: #4a3f1f; } +.ck-btn.danger:hover { color: #ffce5a; border-color: #6a5a2a; } +#ck-src { background: rgba(16,22,28,.8); color: #9fb0bd; border: 1px solid #2a3a46; + border-radius: 8px; font: inherit; font-size: 11px; padding: 6px 8px; cursor: pointer; } + +/* OSD reticle + corner readouts over the feed */ +#ck-reticle { position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; + transform: translate(-50%,-50%); z-index: 24; opacity: .5; } +#ck-reticle::before, #ck-reticle::after { content: ""; position: absolute; + background: rgba(0,212,126,.7); } +#ck-reticle::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-.5px); } +#ck-reticle::after { top: 50%; left: 0; height: 1px; width: 100%; transform: translateY(-.5px); } +.ck-osd { position: absolute; z-index: 24; font-size: 11px; letter-spacing: .1em; + color: rgba(0,212,126,.85); text-shadow: 0 0 8px rgba(0,0,0,.9); + font-variant-numeric: tabular-nums; } +#ck-osd-tl { top: 86px; left: 28px; } +#ck-osd-tr { top: 86px; right: 28px; text-align: right; } +#ck-osd-bl { bottom: 84px; left: 28px; } +.ck-osd .lbl { color: rgba(159,176,189,.6); } + +/* detection boxes drawn over the feed */ +.ck-det { position: absolute; border: 1.5px solid #00d47e; border-radius: 3px; + box-shadow: 0 0 12px rgba(0,212,126,.3); } +.ck-det .tag { position: absolute; top: -17px; left: -1px; font-size: 10px; + background: #00d47e; color: #06080a; padding: 0 5px; border-radius: 3px; + white-space: nowrap; font-weight: 700; letter-spacing: .04em; } + +/* bottom-right minimap */ +#ck-map { position: absolute; right: 16px; bottom: 64px; z-index: 26; width: 200px; + background: rgba(8,12,15,.74); backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); border: 1px solid rgba(42,58,70,.5); + border-radius: 13px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); } +#ck-map .hd { display: flex; justify-content: space-between; padding: 6px 11px; + font-size: 9.5px; letter-spacing: .16em; color: #6b7b88; + border-bottom: 1px solid rgba(31,42,51,.7); } +#ck-map .hd b { color: #00d47e; } +#ck-map canvas { display: block; width: 100%; height: 168px; } + +/* policy slide-in (the live behavior, edited in place) */ +#ck-policy { position: absolute; left: 0; top: 0; bottom: 0; z-index: 28; width: 460px; + max-width: 86vw; transform: translateX(-100%); transition: transform .25s ease; + display: flex; flex-direction: column; + background: rgba(8,11,14,.93); backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); border-right: 1px solid rgba(42,58,70,.55); + box-shadow: 14px 0 40px rgba(0,0,0,.5); } +#ck-policy.open { transform: translateX(0); } +#ck-policy .hd { display: flex; align-items: center; gap: 10px; padding: 13px 16px; + border-bottom: 1px solid #1f2a33; } +#ck-policy .hd b { color: #00d47e; letter-spacing: .14em; font-size: 12px; } +#ck-policy .hd .sub { color: #6b7b88; font-size: 11px; } +#ck-policy .hd .cl { margin-left: auto; cursor: pointer; color: #6b7b88; font-size: 16px; } +#ck-edhost { flex: 1; min-height: 0; overflow: auto; } +#ck-edhost .cm-editor { height: 100%; font-size: 12.5px; } +#ck-edhost textarea { width: 100%; height: 100%; box-sizing: border-box; resize: none; + background: #0a0f13; color: #d7e0e8; border: none; padding: 12px; + font: 12.5px/1.55 ui-monospace, Menlo, monospace; white-space: pre; } +#ck-edhost textarea:focus { outline: none; } +#ck-bar { display: flex; gap: 9px; align-items: center; padding: 11px 16px; + border-top: 1px solid #1f2a33; } +#ck-bar .stat { color: #6b7b88; font-size: 11px; flex: 1; text-align: right; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#ck-bar .stat.ok { color: #00d47e; } #ck-bar .stat.err { color: #d84a4a; } + +/* bottom event ticker */ +#ck-ticker { position: relative; z-index: 30; margin: auto 16px 14px; padding: 8px 16px; + display: flex; align-items: center; gap: 12px; + background: rgba(8,12,15,.72); backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); border: 1px solid rgba(42,58,70,.5); + border-radius: 13px; box-shadow: 0 8px 30px rgba(0,0,0,.45); + font-size: 12px; color: #9fb0bd; min-height: 19px; } +#ck-ticker .src { color: #00d47e; letter-spacing: .1em; font-size: 10px; flex: none; } +#ck-ticker .msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#ck-ticker .deck { margin-left: auto; flex: none; } +#ck-ticker .deck a { color: #3a8a6a; } + +/* network-robot chip (sim modes): a robot on the wifi appears as a source */ +#ck-net { position: fixed; right: 16px; top: 16px; z-index: 80; padding: 8px 14px; + border-radius: 11px; font-size: 11px; letter-spacing: .04em; cursor: pointer; + background: rgba(8,12,15,.85); backdrop-filter: blur(12px); color: #00d47e; + border: 1px solid rgba(0,212,126,.4); box-shadow: 0 6px 22px rgba(0,0,0,.4); + display: none; } + +
+
+ robot camera +
+
+
+
+
+
+ +
+
ROBOT
+
LIVE
+
+
+
ALT
+
SPEED
+
HDG
+
+
+ + + +
+
+ +
+
TACTICALlidar · trail
+ +
+ +
+
BEHAVIOR
+
+
+ + + +
+
+ +
+ + connecting to robot… + flight deck → +
+
+ +
+
diff --git a/roborun/web/arena.js b/roborun/web/arena.js index a9d24e2..d078ce1 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1278,128 +1278,218 @@ async function detectMode() { EYES panel shows the camera frames robot.see() runs YOLO on, and WASD publishes real cmd_vel. Sim → robot without changing pages. */ const RT_BASE = () => (window.ROBORUN_RUNTIME && window.ROBORUN_RUNTIME.base) || ""; -let robotCloudSeen = 0, lastMoveSent = 0; +let lastMoveSent = 0; +let robotPolicyName = ""; +const TYPE_GLYPH = { drone: "✈", quadruped: "◈", humanoid: "⬡", arm: "⚙", webcam_only: "◉" }; +const ckTrail = []; // recent poses, world frame, for the tactical map + +/* ── robot cockpit: the camera is the hero, everything else is a HUD ───── */ function enterRobotMode() { MODE = "robot"; document.body.classList.add("robot-mode"); - if (levelGroup) levelGroup.visible = false; - cloudOn = true; cloud.visible = true; - const grid = new THREE.GridHelper(80, 80, 0x223344, 0x141d26); - grid.position.y = 0.01; - scene.add(grid); - const eyes = document.createElement("div"); - eyes.id = "robotEyes"; - eyes.style.cssText = - "position:fixed;right:12px;top:12px;z-index:999;width:320px;" + - "border:1px solid #2a333d;border-radius:6px;overflow:hidden;background:#000;"; - eyes.innerHTML = - '
EYES' + - '
' + - ``; - document.body.appendChild(eyes); - document.getElementById("eyesSrc").addEventListener("change", (e) => { - document.getElementById("eyesImg").src = - `${RT_BASE()}/api/camera/stream?source=${e.target.value}&t=${Date.now()}`; - }); - // the policy panel edits what is actually running on the robot — no - // sandbox starter, no silent deploys - const phead = document.querySelector("#p-policy .p-head span"); - if (phead) phead.textContent = "— live on the connected robot · RUN hot-reloads it"; - const keysEl = document.getElementById("keys"); - if (keysEl) keysEl.innerHTML = "drag headers · resize corners · ⌘⏎ deploy · " + - 'WASD drives the real robot' + - ' · deck'; - if (mainCamSel) mainCamSel.value = "chase"; + const st = document.getElementById("start"); + if (st) st.classList.remove("show"); + + // poll single frames rather than an MJPEG stream — deterministic, + // never half-paints, and lets us swap source instantly + let camSource = "robot"; + $("ck-src").addEventListener("change", (e) => { camSource = e.target.value; }); + (function pumpCam() { + const img = new Image(); + img.onload = () => { $("ck-cam").src = img.src; setTimeout(pumpCam, 90); }; + img.onerror = () => setTimeout(pumpCam, 400); + img.src = `${RT_BASE()}/api/camera/frame?source=${camSource}&t=${Date.now()}`; + })(); + + // policy slide-in toggles + const pol = $("ck-policy"); + $("ck-policy-btn").addEventListener("click", () => pol.classList.toggle("open")); + $("ck-policy-close").addEventListener("click", () => pol.classList.remove("open")); + $("ck-hold").addEventListener("click", ckStop); + $("ck-deploy").addEventListener("click", ckDeploy); + $("ck-stop").addEventListener("click", ckStop); + + // identify where this robot lives (host + transport) + fetch("/api/sources").then((r) => r.json()).then((s) => { + const h = (s.robot && s.robot.host) || "127.0.0.1"; + const local = h === "127.0.0.1" || h === "localhost"; + $("ck-where").textContent = `${local ? "local sim" : "network"} · rosbridge ${h}`; + }).catch(() => {}); + loadRobotBehavior(); pollRobot(); + pollRobotDetections(); + ckTicker(); } -let robotPolicyName = "robot_policy"; +function $(id) { return document.getElementById(id); } + async function loadRobotBehavior() { try { const r = await (await fetch("/api/behaviors")).json(); const live = (r.behaviors || []).find((b) => b.enabled && !["fix_camera", "heartbeat", "player_policy"].includes(b.name)); - if (!live) { policyStatus("no behavior running — write one and RUN", ""); return; } + if (!live) { ckPStat("no behavior running — write one and DEPLOY", ""); return; } robotPolicyName = live.name; + $("ck-pname").textContent = live.name; const src = await api("/api/behaviors/read", { name: live.name }); - if (src.ok) { - setCode(src.source); - policyStatus(`editing ${live.name} — live on the robot, RUN applies`, "ok"); + if (src.ok) { $("ck-code").value = src.source; ckPStat(`live: ${live.name}`, "ok"); } + } catch {} +} +function ckPStat(msg, cls) { + const el = $("ck-pstat"); el.textContent = msg; el.className = "stat " + (cls || ""); +} +async function ckDeploy() { + const source = $("ck-code").value; + const name = robotPolicyName || "robot_policy"; + if (!source.includes("@behavior")) { ckPStat("needs an @behavior function", "err"); return; } + if (!confirm(`Deploy "${name}" to the CONNECTED ROBOT?\nIt hot-reloads and moves hardware immediately.`)) { + ckPStat("deploy cancelled", ""); return; + } + ckPStat("saving…", ""); + const w = await api("/api/behaviors/write", { name, source }); + if (!w.ok) { ckPStat(w.error || "write failed", "err"); return; } + await api("/api/behaviors/enable", { name }); + robotPolicyName = name; $("ck-pname").textContent = name; + ckPStat(`live on the robot — DEPLOY applies edits`, "ok"); +} +async function ckStop() { + const name = robotPolicyName || "robot_policy"; + await api("/api/behaviors/disable", { name }).catch(() => {}); + ckPStat(`${name} stopped — robot holds`, ""); +} + +async function pollRobot() { + try { + const r = await (await fetch("/api/ros/cloud")).json(); + const p = r.pose; + if (p) { + const now = performance.now(); + if (ckTrail.length) { + const prev = ckTrail[ckTrail.length - 1]; + const d = Math.hypot(p.x - prev.x, p.z - prev.z); + const dtspeed = d / Math.max(0.001, (now - (prev.t || now)) / 1000); + $("ck-spd").textContent = (isFinite(dtspeed) ? dtspeed : 0).toFixed(1); + } + ckTrail.push({ x: p.x, z: p.z, t: now }); + if (ckTrail.length > 240) ckTrail.shift(); + const isDrone = r.robot_type === "drone"; + const altChip = $("ck-alt").parentElement; + if (isDrone) { + $("ck-alt").textContent = (p.y ?? 0).toFixed(1) + "m"; + altChip.style.display = ""; + altChip.classList.toggle("warn", (p.y ?? 0) > 8); + } else { altChip.style.display = "none"; } + const hdg = ((p.heading || 0) * 180 / Math.PI + 360) % 360; + $("ck-hdg").textContent = hdg.toFixed(0) + "°"; + $("ck-osd-tl").innerHTML = + `POS ${p.x.toFixed(1)}, ${p.z.toFixed(1)}` + + (isDrone ? `
ALT ${(p.y ?? 0).toFixed(2)} m` : ""); + $("ck-osd-tr").innerHTML = `HDG ${hdg.toFixed(0)}°`; + } + if (r.robot_type) { + $("ck-type").textContent = r.robot_type.replace("_", " ").toUpperCase(); + $("ck-glyph").textContent = TYPE_GLYPH[r.robot_type] || "◈"; + // the map shows what the robot actually senses + $("ck-map-tag").textContent = (r.lidar && r.lidar.length) ? "lidar · trail" : "trail"; } + drawTacticalMap(r); } catch {} + setTimeout(pollRobot, 150); } -/* ── network robots: surface rosbridges found on the LAN ───────────────── - In sim modes the runtime scans the local /24 for :9090; anything found - appears as a chip — one click connects and the page reloads straight - into robot mode. A robot on your wifi is a source, not a config step. */ +function drawTacticalMap(r) { + const cv = $("ck-mapcv"); if (!cv) return; + const ctx = cv.getContext("2d"); const W = cv.width, H = cv.height; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = "#070b0e"; ctx.fillRect(0, 0, W, H); + const cx = W / 2, cy = H / 2; + // world auto-scale around the robot's recent travel + let span = 8; + for (const t of ckTrail) span = Math.max(span, Math.abs(t.x - (r.pose?.x || 0)), + Math.abs(t.z - (r.pose?.z || 0))); + const sc = (Math.min(W, H) / 2 - 10) / span; + // range rings + ctx.strokeStyle = "rgba(42,58,70,.5)"; ctx.lineWidth = 1; + for (let i = 1; i <= 3; i++) { ctx.beginPath(); + ctx.arc(cx, cy, (Math.min(W, H) / 2 - 10) * i / 3, 0, 7); ctx.stroke(); } + const rx = r.pose?.x || 0, rz = r.pose?.z || 0; + // accumulated point cloud (lidar / spatial memory) + ctx.fillStyle = "rgba(0,212,126,.5)"; + for (const pt of (r.points || [])) { + const px = cx + (pt[0] - rx) * sc, py = cy + (pt[1] - rz) * sc; + if (px >= 0 && px < W && py >= 0 && py < H) ctx.fillRect(px, py, 1.5, 1.5); + } + // trail + ctx.strokeStyle = "rgba(0,212,126,.55)"; ctx.lineWidth = 1.5; ctx.beginPath(); + ckTrail.forEach((t, i) => { const px = cx + (t.x - rx) * sc, py = cy + (t.z - rz) * sc; + i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); }); + ctx.stroke(); + // robot + heading wedge + const hd = r.pose?.heading || 0; + ctx.fillStyle = "#00d47e"; + ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, 7); ctx.fill(); + ctx.strokeStyle = "rgba(0,212,126,.8)"; ctx.beginPath(); + ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hd) * 14, cy - Math.sin(hd) * 14); ctx.stroke(); +} + +async function pollRobotDetections() { + try { + const r = await (await fetch("/api/robot/detections")).json(); + const ov = $("ck-overlay"); ov.innerHTML = ""; + // the feed is object-fit:cover — replicate that crop so boxes line up + const iw = r.w || 16, ih = r.h || 9, vw = innerWidth, vh = innerHeight; + const scale = Math.max(vw / iw, vh / ih); + const dw = iw * scale, dh = ih * scale, ox = (vw - dw) / 2, oy = (vh - dh) / 2; + for (const d of (r.detections || [])) { + const box = document.createElement("div"); box.className = "ck-det"; + box.style.left = (ox + d.x * dw) + "px"; + box.style.top = (oy + d.y * dh) + "px"; + box.style.width = (d.w * dw) + "px"; + box.style.height = (d.h * dh) + "px"; + box.innerHTML = `${d.label} ${(d.conf * 100).toFixed(0)}%`; + ov.appendChild(box); + } + } catch {} + setTimeout(pollRobotDetections, 250); +} + +async function ckTicker() { + try { + const evs = (await (await fetch("/api/run/events")).json()).events || []; + const interesting = evs.filter((e) => + ["follow_person_drone", "fix_camera", "ros", "frame"].includes(e.source) || + (e.title && /follow|move|person|cmd/i.test(e.title))); + const last = interesting[interesting.length - 1] || evs[evs.length - 1]; + if (last) { + $("ck-tick-src").textContent = "● " + (last.source || "robot"); + $("ck-tick-msg").textContent = last.title || ""; + } + } catch {} + setTimeout(ckTicker, 700); +} + +/* ── network robots (sim modes): a rosbridge on the wifi is a source ───── */ async function pollNetworkRobots() { if (MODE === "robot") return; try { const r = await (await fetch("/api/sources")).json(); const found = (r.network && r.network.found) || []; - let chip = document.getElementById("netRobots"); + const chip = $("ck-net"); if (found.length && !r.robot.connected) { - if (!chip) { - chip = document.createElement("div"); - chip.id = "netRobots"; - chip.style.cssText = - "position:fixed;right:12px;top:12px;z-index:999;padding:5px 12px;" + - "border-radius:12px;font:11px/1.6 ui-monospace,Menlo,monospace;" + - "background:#11161bcc;border:1px solid #00d47e55;color:#00d47e;" + - "cursor:pointer;user-select:none;"; - document.body.appendChild(chip); - } const r0 = found.find((f) => f.local) || found[0]; chip.textContent = `◈ robot on network — ${r0.host}:${r0.port} · click to connect`; + chip.style.display = "block"; chip.onclick = async () => { chip.textContent = "connecting…"; const res = await api("/api/ros/connect", { host: r0.host, port: r0.port }); if (res.ok) location.reload(); else chip.textContent = `connect failed: ${res.error || "?"}`; }; - } else if (chip) { - chip.remove(); - } + } else { chip.style.display = "none"; } } catch {} setTimeout(pollNetworkRobots, 10000); } -async function pollRobot() { - try { - const r = await (await fetch("/api/ros/cloud")).json(); - if (r.pose) { - bot.pos.x = r.pose.x; - bot.pos.z = r.pose.z; - bot.heading = r.pose.heading || 0; - if (r.pose.y !== undefined && r.pose.y !== null) bot.alt = r.pose.y; - if (bot.group) { - bot.group.position.set(bot.pos.x, bot.type === "drone" ? bot.alt : 0, bot.pos.z); - bot.group.rotation.y = bot.heading; - } - } - if (r.robot_type === "drone" && bot.type !== "drone") { - bot.type = "drone"; - buildBody("drone"); - } - const pts = r.points || []; - if (pts.length < robotCloudSeen) robotCloudSeen = 0; // server restarted - for (let i = robotCloudSeen; i < pts.length; i++) - cloudAdd(pts[i][0], 0.12, pts[i][1], pts[i][2]); - robotCloudSeen = pts.length; - cloudCommit(); - if (r.lidar && r.lidar.length) { - lastLidar = r.lidar; - integrateLidar(lastLidar); - } - drawMap(); - updateTelemetry(); - } catch {} - setTimeout(pollRobot, 150); -} + function sendRobotMove(cmd) { const now = performance.now(); if (now - lastMoveSent < 120) return; @@ -1668,6 +1758,7 @@ function animateStart(t) { if (startEl.classList.contains("show")) startRaf = requestAnimationFrame(animateStart); } function showStart() { + if (MODE === "robot") return; // sim onboarding — never over a live robot startEl.classList.add("show"); cancelAnimationFrame(startRaf); startRaf = requestAnimationFrame(animateStart); @@ -1678,7 +1769,8 @@ function enterLevel(i) { policyStatus("starter policy loaded — edit it (or don't), then press ▶ RUN · WASD grabs the wheel anytime", "ok"); } buildStartScreen(); -showStart(); +// splash shows after mode detection — a connected robot boots into its +// cockpit, not into "pick a robot, pick a task" document.getElementById("startClose").addEventListener("click", () => startEl.classList.remove("show")); startEl.addEventListener("click", (e) => { if (e.target === startEl) startEl.classList.remove("show"); }); document.getElementById("btnLevels").addEventListener("click", showStart); @@ -2116,7 +2208,7 @@ function frame(now) { await initPhysics(); // rapier WASM, once per page loadLevel(0); -detectMode().then(() => pollNetworkRobots()); pollCmd(); pushState(); pollSightings(); renderRuns(); +detectMode().then(() => { pollNetworkRobots(); if (MODE !== "robot") showStart(); }); pollCmd(); pushState(); pollSightings(); renderRuns(); requestAnimationFrame(frame); /* harness hook — scripts/e2e_arena.mjs drives the page without the UI */ From 186ed823bdf45ced8636dbc365ae7bd11d77e891 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 22:45:14 -0400 Subject: [PATCH 007/126] Cockpit, fuller: source picker, syntax-highlighted policy, live timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering 'I don't want the drone — give me something else even though ROS is connected', and making the cockpit a complete shell: - SOURCE picker: every robot/sim this runtime can reach in one menu — the connected robot (live), the browser sim arena, and any rosbridge found on the LAN. Pick the sim and the page pins to it (localStorage) without disconnecting the robot; a chip offers the way back. Multiple ROS robots just appear. - the policy editor is syntax-highlighted now (a colored
 underlay
  behind a transparent textarea — keywords, defs, strings, decorators,
  comments, numbers) instead of plain white-on-black.
- TIMELINE panel (bottom-left, mirroring the tactical map) streams the
  robot's decisions and sightings with timestamps and source-colored dots —
  the same stream+map→timeline surface the sim arena has, so the experience
  is consistent whatever the source is.
- DECK link moves into the top bar.

The cockpit shell — camera stream (hero), tactical map (objects, moving vs
stationary, range rings), timeline, policy — is now one consistent UX; what
fills it is the source.
---
 roborun/web/arena.html |  93 +++++++++---
 roborun/web/arena.js   | 323 +++++++++++++++++++++++++++++++++++------
 2 files changed, 346 insertions(+), 70 deletions(-)

diff --git a/roborun/web/arena.html b/roborun/web/arena.html
index 9f9802c..a4f5afa 100644
--- a/roborun/web/arena.html
+++ b/roborun/web/arena.html
@@ -309,29 +309,70 @@
 #ck-policy .hd b { color: #00d47e; letter-spacing: .14em; font-size: 12px; }
 #ck-policy .hd .sub { color: #6b7b88; font-size: 11px; }
 #ck-policy .hd .cl { margin-left: auto; cursor: pointer; color: #6b7b88; font-size: 16px; }
-#ck-edhost { flex: 1; min-height: 0; overflow: auto; }
-#ck-edhost .cm-editor { height: 100%; font-size: 12.5px; }
-#ck-edhost textarea { width: 100%; height: 100%; box-sizing: border-box; resize: none;
-            background: #0a0f13; color: #d7e0e8; border: none; padding: 12px;
-            font: 12.5px/1.55 ui-monospace, Menlo, monospace; white-space: pre; }
-#ck-edhost textarea:focus { outline: none; }
+/* syntax-highlighted editor: a colored 
 underlay behind a transparent
+   textarea, kept in perfect sync — looks like a real editor, stays editable */
+#ck-edhost { flex: 1; min-height: 0; position: relative; overflow: hidden; background: #0a0f13; }
+#ck-hl, #ck-code { position: absolute; inset: 0; margin: 0; box-sizing: border-box;
+            padding: 14px 16px; border: 0; width: 100%; height: 100%;
+            font: 12.5px/1.6 ui-monospace, "SF Mono", Menlo, monospace;
+            white-space: pre; tab-size: 4; overflow: auto; }
+#ck-hl { z-index: 1; pointer-events: none; color: #d7e0e8; }
+#ck-hl code { font: inherit; }
+#ck-code { z-index: 2; background: transparent; color: transparent;
+           caret-color: #00d47e; resize: none; }
+#ck-code:focus { outline: none; }
+/* python token colors */
+.tk-kw { color: #c678dd; } .tk-def { color: #61afef; } .tk-str { color: #98c379; }
+.tk-com { color: #5a6b78; font-style: italic; } .tk-num { color: #d19a66; }
+.tk-dec { color: #e0a030; } .tk-bn { color: #56b6c2; }
 #ck-bar { display: flex; gap: 9px; align-items: center; padding: 11px 16px;
           border-top: 1px solid #1f2a33; }
 #ck-bar .stat { color: #6b7b88; font-size: 11px; flex: 1; text-align: right;
                 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 #ck-bar .stat.ok { color: #00d47e; } #ck-bar .stat.err { color: #d84a4a; }
 
-/* bottom event ticker */
-#ck-ticker { position: relative; z-index: 30; margin: auto 16px 14px; padding: 8px 16px;
-             display: flex; align-items: center; gap: 12px;
-             background: rgba(8,12,15,.72); backdrop-filter: blur(14px);
-             -webkit-backdrop-filter: blur(14px); border: 1px solid rgba(42,58,70,.5);
-             border-radius: 13px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
-             font-size: 12px; color: #9fb0bd; min-height: 19px; }
-#ck-ticker .src { color: #00d47e; letter-spacing: .1em; font-size: 10px; flex: none; }
-#ck-ticker .msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-#ck-ticker .deck { margin-left: auto; flex: none; }
-#ck-ticker .deck a { color: #3a8a6a; }
+/* bottom-left timeline — the event stream the robot generates from what it
+   sees and decides; mirrors the tactical map on the right */
+#ck-timeline { position: absolute; left: 16px; bottom: 16px; z-index: 26; width: 360px;
+               max-width: 40vw; background: rgba(8,12,15,.74); backdrop-filter: blur(14px);
+               -webkit-backdrop-filter: blur(14px); border: 1px solid rgba(42,58,70,.5);
+               border-radius: 13px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); }
+#ck-timeline .hd { display: flex; justify-content: space-between; padding: 8px 13px;
+                   font-size: 9.5px; letter-spacing: .16em; color: #6b7b88;
+                   border-bottom: 1px solid rgba(31,42,51,.7); }
+#ck-timeline .hd b { color: #00d47e; font-weight: 400; }
+#ck-tl-list { max-height: 168px; overflow: hidden; display: flex; flex-direction: column; }
+.ck-tl-row { display: flex; align-items: baseline; gap: 9px; padding: 5px 13px;
+             font-size: 11.5px; border-bottom: 1px solid rgba(31,42,51,.32);
+             animation: ckRowIn .2s ease-out; }
+@keyframes ckRowIn { from { opacity: 0; transform: translateY(-4px); } }
+.ck-tl-row .t { color: #4a5a66; font-size: 10px; flex: none; font-variant-numeric: tabular-nums; }
+.ck-tl-row .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; align-self: center; }
+.ck-tl-row .m { color: #9fb0bd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* source picker dropdown */
+#ck-sources { position: absolute; z-index: 40; right: 16px; top: 60px; width: 320px;
+              display: none; background: rgba(10,14,18,.96); backdrop-filter: blur(16px);
+              -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(42,58,70,.6);
+              border-radius: 13px; box-shadow: 0 14px 44px rgba(0,0,0,.55); overflow: hidden; }
+#ck-sources.open { display: block; }
+#ck-sources .hd { padding: 11px 15px 7px; font-size: 9.5px; letter-spacing: .16em;
+                  color: #6b7b88; border-bottom: 1px solid rgba(31,42,51,.7); }
+#ck-sources .ft { padding: 9px 15px; font-size: 10.5px; color: #5a6b78;
+                  border-top: 1px solid rgba(31,42,51,.6); }
+.ck-source { display: flex; align-items: center; gap: 11px; padding: 11px 15px; cursor: pointer;
+             border-bottom: 1px solid rgba(31,42,51,.45); transition: background .12s; }
+.ck-source:hover { background: rgba(0,212,126,.07); }
+.ck-source .ic { font-size: 16px; width: 20px; text-align: center; color: #6b7b88; }
+.ck-source.active .ic, .ck-source.active .nm { color: #00d47e; }
+.ck-source .meta { flex: 1; min-width: 0; }
+.ck-source .nm { font-size: 12.5px; color: #d7e0e8; letter-spacing: .04em; }
+.ck-source .sub { font-size: 10.5px; color: #6b7b88; white-space: nowrap;
+                  overflow: hidden; text-overflow: ellipsis; }
+.ck-source .badge { font-size: 9px; letter-spacing: .12em; padding: 2px 7px; border-radius: 6px;
+                    flex: none; }
+.ck-source .badge.live { color: #00d47e; border: 1px solid rgba(0,212,126,.4); }
+.ck-source .badge.go { color: #06080a; background: #00d47e; }
 
 /* network-robot chip (sim modes): a robot on the wifi appears as a source */
 #ck-net { position: fixed; right: 16px; top: 16px; z-index: 80; padding: 8px 14px;
@@ -364,15 +405,24 @@
       
HDG
+ + ⊟ DECK
+ +
+
SOURCE — what you're controlling
+
+
a robot on your wifi appears here automatically
+
+
TACTICALlidar · trail
@@ -380,7 +430,7 @@
BEHAVIOR
-
+
@@ -388,10 +438,9 @@
-
- - connecting to robot… - flight deck → +
+
TIMELINElive decisions & sightings
+
diff --git a/roborun/web/arena.js b/roborun/web/arena.js index d078ce1..5996031 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1251,14 +1251,18 @@ async function api(path, body) { let MODE = "detect"; // "server" | "wasm" let wasmRT = null, wasmLoading = false; async function detectMode() { - // a connected robot wins: the arena renders *its* reality, not the sim's - try { - const ctl = new AbortController(); - const t = setTimeout(() => ctl.abort(), 1500); - const r = await fetch("/api/ros/health", { signal: ctl.signal }); - clearTimeout(t); - if (r.ok && (await r.json()).connected) { enterRobotMode(); return; } - } catch {} + // a connected robot wins by default — but the user can pin a source (the + // SOURCE picker), e.g. to work in the sim while a robot stays connected + const pinned = localStorage.getItem("roborun.source"); + if (pinned !== "sim") { + try { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 1500); + const r = await fetch("/api/ros/health", { signal: ctl.signal }); + clearTimeout(t); + if (r.ok && (await r.json()).connected) { enterRobotMode(); return; } + } catch {} + } try { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 1500); @@ -1282,6 +1286,14 @@ let lastMoveSent = 0; let robotPolicyName = ""; const TYPE_GLYPH = { drone: "✈", quadruped: "◈", humanoid: "⬡", arm: "⚙", webcam_only: "◉" }; const ckTrail = []; // recent poses, world frame, for the tactical map +let ckPose = null; // latest robot pose, shared across pollers +const ckObjects = new Map(); // track_id → world-tracked object (moving vs stationary) +const CK_HFOV = 1.0472; // camera horizontal FOV (rad) — matches the X3 cam +const CLASS_H = { person: 1.7, car: 1.5, truck: 2.6, bus: 3.0, bicycle: 1.1, + motorcycle: 1.2, "dog": 0.6, "cat": 0.3, "chair": 0.9 }; +const CLASS_COLOR = { person: "#00d47e", car: "#e0a030", truck: "#e0a030", + bus: "#e0a030", bicycle: "#40a0e0", motorcycle: "#40a0e0" }; +const ckColor = (label) => CLASS_COLOR[label] || "#9fb0bd"; /* ── robot cockpit: the camera is the hero, everything else is a HUD ───── */ function enterRobotMode() { @@ -1301,6 +1313,20 @@ function enterRobotMode() { img.src = `${RT_BASE()}/api/camera/frame?source=${camSource}&t=${Date.now()}`; })(); + // syntax-highlighted policy editor: keep the underlay in sync on edit/scroll + const code = $("ck-code"), hl = $("ck-hl"); + const sync = () => { hl.firstChild.innerHTML = ckHighlight(code.value); + hl.scrollTop = code.scrollTop; hl.scrollLeft = code.scrollLeft; }; + code.addEventListener("input", sync); + code.addEventListener("scroll", () => { hl.scrollTop = code.scrollTop; hl.scrollLeft = code.scrollLeft; }); + code.addEventListener("keydown", (e) => { // tab inserts spaces + if (e.key === "Tab") { e.preventDefault(); + const s = code.selectionStart, en = code.selectionEnd; + code.value = code.value.slice(0, s) + " " + code.value.slice(en); + code.selectionStart = code.selectionEnd = s + 4; sync(); } + }); + window.__ckSyncCode = sync; + // policy slide-in toggles const pol = $("ck-policy"); $("ck-policy-btn").addEventListener("click", () => pol.classList.toggle("open")); @@ -1309,6 +1335,17 @@ function enterRobotMode() { $("ck-deploy").addEventListener("click", ckDeploy); $("ck-stop").addEventListener("click", ckStop); + // source picker + const srcBtn = $("ck-source-btn"), srcMenu = $("ck-sources"); + srcBtn.addEventListener("click", (e) => { + e.stopPropagation(); + srcMenu.classList.toggle("open"); + if (srcMenu.classList.contains("open")) buildSourceMenu(); + }); + document.addEventListener("click", (e) => { + if (!srcMenu.contains(e.target) && e.target !== srcBtn) srcMenu.classList.remove("open"); + }); + // identify where this robot lives (host + transport) fetch("/api/sources").then((r) => r.json()).then((s) => { const h = (s.robot && s.robot.host) || "127.0.0.1"; @@ -1319,10 +1356,89 @@ function enterRobotMode() { loadRobotBehavior(); pollRobot(); pollRobotDetections(); - ckTicker(); + ckTimeline(); } function $(id) { return document.getElementById(id); } +/* lightweight Python highlighter for the policy underlay. Tokenizes in one + pass so strings/comments swallow keywords inside them. */ +const CK_KW = new Set(("def return if elif else for while in and or not is None True False " + + "import from as with try except finally class lambda yield break continue pass global " + + "nonlocal raise assert del await async").split(" ")); +const CK_BUILTIN = new Set("robot self range len min max abs int float str print round sum".split(" ")); +function ckEsc(s) { return s.replace(/&/g, "&").replace(//g, ">"); } +function ckHighlight(src) { + let out = "", i = 0, n = src.length; + while (i < n) { + const c = src[i]; + if (c === "#") { let j = i; while (j < n && src[j] !== "\n") j++; + out += `${ckEsc(src.slice(i, j))}`; i = j; continue; } + if (c === '"' || c === "'") { + const triple = src.slice(i, i + 3); + if (triple === '"""' || triple === "'''") { // docstring / block string + let j = i + 3; while (j < n && src.slice(j, j + 3) !== triple) j++; + out += `${ckEsc(src.slice(i, j + 3))}`; i = j + 3; continue; + } + const q = c; let j = i + 1; + while (j < n && src[j] !== q && src[j] !== "\n") { if (src[j] === "\\") j++; j++; } + out += `${ckEsc(src.slice(i, j + 1))}`; i = j + 1; continue; } + if (c === "@") { let j = i + 1; while (j < n && /[\w.]/.test(src[j])) j++; + out += `${ckEsc(src.slice(i, j))}`; i = j; continue; } + if (/[A-Za-z_]/.test(c)) { let j = i; while (j < n && /[\w]/.test(src[j])) j++; + const w = src.slice(i, j); + const prev = src.slice(0, i).trimEnd(); + const cls = CK_KW.has(w) ? "tk-kw" + : (prev.endsWith("def") || prev.endsWith("class")) ? "tk-def" + : CK_BUILTIN.has(w) ? "tk-bn" : null; + out += cls ? `${w}` : ckEsc(w); i = j; continue; } + if (/[0-9]/.test(c)) { let j = i; while (j < n && /[\d.]/.test(src[j])) j++; + out += `${ckEsc(src.slice(i, j))}`; i = j; continue; } + out += ckEsc(c); i++; + } + return out; +} + +/* the source picker: every robot / sim this runtime can reach, in one menu. + A connected robot, a sim arena, and any rosbridge discovered on the LAN — + pick one and the page switches to it. Answers "I don't want the drone, + give me something else even though ROS is connected." */ +async function buildSourceMenu() { + const list = $("ck-src-list"); + list.innerHTML = '
' + + '
scanning…
'; + let s = {}; + try { s = await (await fetch("/api/sources")).json(); } catch {} + const rows = []; + // the connected robot (active) + if (s.robot && s.robot.connected) { + rows.push({ ic: TYPE_GLYPH[s.robot.type] || "◈", + nm: (s.robot.type || "robot").replace("_", " ").toUpperCase(), + sub: `rosbridge ${s.robot.host}`, badge: "live", active: true, + onPick: () => $("ck-sources").classList.remove("open") }); + } + // other rosbridges on the network + for (const f of ((s.network && s.network.found) || [])) { + if (s.robot && s.robot.connected && f.host === s.robot.host) continue; + rows.push({ ic: "◈", nm: "ROBOT", sub: `rosbridge ${f.host}:${f.port}`, badge: "go", + onPick: async () => { await api("/api/ros/connect", { host: f.host, port: f.port }); + localStorage.removeItem("roborun.source"); location.reload(); } }); + } + // the browser sim arena — leave the robot without disconnecting it + rows.push({ ic: "◐", nm: "SIM ARENA", sub: "browser physics · code & test policies", + onPick: () => { localStorage.setItem("roborun.source", "sim"); location.reload(); } }); + + list.innerHTML = ""; + for (const r of rows) { + const el = document.createElement("div"); + el.className = "ck-source" + (r.active ? " active" : ""); + el.innerHTML = `${r.ic}
` + + `
${r.nm}
${r.sub}
` + + (r.badge ? `${r.badge === "go" ? "CONNECT" : "LIVE"}` : ""); + el.addEventListener("click", r.onPick); + list.appendChild(el); + } +} + async function loadRobotBehavior() { try { const r = await (await fetch("/api/behaviors")).json(); @@ -1332,7 +1448,9 @@ async function loadRobotBehavior() { robotPolicyName = live.name; $("ck-pname").textContent = live.name; const src = await api("/api/behaviors/read", { name: live.name }); - if (src.ok) { $("ck-code").value = src.source; ckPStat(`live: ${live.name}`, "ok"); } + if (src.ok) { $("ck-code").value = src.source; + if (window.__ckSyncCode) window.__ckSyncCode(); + ckPStat(`live: ${live.name}`, "ok"); } } catch {} } function ckPStat(msg, cls) { @@ -1362,6 +1480,7 @@ async function pollRobot() { try { const r = await (await fetch("/api/ros/cloud")).json(); const p = r.pose; + ckPose = p || ckPose; if (p) { const now = performance.now(); if (ckTrail.length) { @@ -1397,75 +1516,177 @@ async function pollRobot() { setTimeout(pollRobot, 150); } +/* range + bearing of a detection, projected to a world (x,z) point using + the drone's pose. Distance comes from how tall the object stands in the + frame against its real-world height — the same monocular cue the follow + behavior leans on, good to roughly ±20%. */ +function projectDetection(d, pose, aspect) { + const realH = CLASS_H[d.label] || 1.4; + const vfov = 2 * Math.atan(Math.tan(CK_HFOV / 2) * aspect); + const dist = Math.max(0.6, Math.min(60, realH / (2 * Math.tan(vfov / 2) * Math.max(0.02, d.h)))); + const cxn = d.x + d.w / 2; + const bearing = (pose.heading || 0) + (0.5 - cxn) * CK_HFOV; + return { dist, bearing, + wx: pose.x + Math.cos(bearing) * dist, + wz: pose.z - Math.sin(bearing) * dist }; +} + function drawTacticalMap(r) { const cv = $("ck-mapcv"); if (!cv) return; const ctx = cv.getContext("2d"); const W = cv.width, H = cv.height; + const cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 10; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#070b0e"; ctx.fillRect(0, 0, W, H); - const cx = W / 2, cy = H / 2; - // world auto-scale around the robot's recent travel - let span = 8; - for (const t of ckTrail) span = Math.max(span, Math.abs(t.x - (r.pose?.x || 0)), - Math.abs(t.z - (r.pose?.z || 0))); - const sc = (Math.min(W, H) / 2 - 10) / span; - // range rings - ctx.strokeStyle = "rgba(42,58,70,.5)"; ctx.lineWidth = 1; - for (let i = 1; i <= 3; i++) { ctx.beginPath(); - ctx.arc(cx, cy, (Math.min(W, H) / 2 - 10) * i / 3, 0, 7); ctx.stroke(); } const rx = r.pose?.x || 0, rz = r.pose?.z || 0; - // accumulated point cloud (lidar / spatial memory) - ctx.fillStyle = "rgba(0,212,126,.5)"; + // auto-scale to hold the trail and every tracked object in view + let span = 6; + for (const t of ckTrail) span = Math.max(span, Math.abs(t.x - rx), Math.abs(t.z - rz)); + for (const o of ckObjects.values()) span = Math.max(span, Math.abs(o.wx - rx) + 1, Math.abs(o.wz - rz) + 1); + const sc = R / span; + // range rings + their real distance, so the map reads in meters + ctx.font = "8px ui-monospace, Menlo, monospace"; + for (let i = 1; i <= 3; i++) { + ctx.strokeStyle = "rgba(42,58,70,.5)"; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(cx, cy, R * i / 3, 0, 7); ctx.stroke(); + ctx.fillStyle = "rgba(90,107,120,.7)"; + ctx.fillText((span * i / 3).toFixed(0) + "m", cx + 2, cy - R * i / 3 + 9); + } + // lidar / accumulated cloud (ground robots; drone has none) + ctx.fillStyle = "rgba(0,212,126,.4)"; for (const pt of (r.points || [])) { const px = cx + (pt[0] - rx) * sc, py = cy + (pt[1] - rz) * sc; if (px >= 0 && px < W && py >= 0 && py < H) ctx.fillRect(px, py, 1.5, 1.5); } - // trail - ctx.strokeStyle = "rgba(0,212,126,.55)"; ctx.lineWidth = 1.5; ctx.beginPath(); + // travel trail + ctx.strokeStyle = "rgba(0,212,126,.5)"; ctx.lineWidth = 1.5; ctx.beginPath(); ckTrail.forEach((t, i) => { const px = cx + (t.x - rx) * sc, py = cy + (t.z - rz) * sc; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); }); ctx.stroke(); - // robot + heading wedge + // tracked objects — stationary as hollow squares, movers filled with a + // heading tail, the followed target ringed + const now = performance.now(); + for (const o of ckObjects.values()) { + const px = cx + (o.wx - rx) * sc, py = cy + (o.wz - rz) * sc; + if (px < 2 || px > W - 2 || py < 2 || py > H - 2) continue; + const col = ckColor(o.label); + if (o.followed) { + const pulse = 0.5 + 0.5 * Math.sin(now / 240); + ctx.strokeStyle = col; ctx.globalAlpha = 0.4 + 0.6 * pulse; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(px, py, 6 + pulse * 2, 0, 7); ctx.stroke(); + ctx.globalAlpha = 1; + } + if (o.moving) { + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(px, py, 2.6, 0, 7); ctx.fill(); + if (o.vx !== undefined) { ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.beginPath(); + ctx.moveTo(px, py); ctx.lineTo(px + o.vx * sc * 1.2, py + o.vz * sc * 1.2); ctx.stroke(); } + } else { + ctx.strokeStyle = col; ctx.globalAlpha = 0.8; ctx.lineWidth = 1.2; + ctx.strokeRect(px - 2.5, py - 2.5, 5, 5); ctx.globalAlpha = 1; + } + } + // robot + heading wedge, always on top const hd = r.pose?.heading || 0; - ctx.fillStyle = "#00d47e"; - ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, 7); ctx.fill(); - ctx.strokeStyle = "rgba(0,212,126,.8)"; ctx.beginPath(); - ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hd) * 14, cy - Math.sin(hd) * 14); ctx.stroke(); + ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, 7); ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,.85)"; ctx.lineWidth = 1.5; ctx.beginPath(); + ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hd) * 15, cy - Math.sin(hd) * 15); ctx.stroke(); } async function pollRobotDetections() { try { const r = await (await fetch("/api/robot/detections")).json(); const ov = $("ck-overlay"); ov.innerHTML = ""; - // the feed is object-fit:cover — replicate that crop so boxes line up const iw = r.w || 16, ih = r.h || 9, vw = innerWidth, vh = innerHeight; const scale = Math.max(vw / iw, vh / ih); const dw = iw * scale, dh = ih * scale, ox = (vw - dw) / 2, oy = (vh - dh) / 2; - for (const d of (r.detections || [])) { + const pose = ckPose, aspect = ih / iw, now = performance.now(); + const dets = r.detections || []; + // pick the followed target the way follow_person_drone does: the + // highest-confidence person + let followed = null; + for (const d of dets) if (d.label === "person" && + (!followed || d.conf > followed.conf)) followed = d; + + const seen = new Set(); + for (const d of dets) { + const proj = pose ? projectDetection(d, pose, aspect) : null; + // draw the camera box, with live distance when we can estimate it const box = document.createElement("div"); box.className = "ck-det"; - box.style.left = (ox + d.x * dw) + "px"; - box.style.top = (oy + d.y * dh) + "px"; - box.style.width = (d.w * dw) + "px"; - box.style.height = (d.h * dh) + "px"; - box.innerHTML = `${d.label} ${(d.conf * 100).toFixed(0)}%`; + box.style.left = (ox + d.x * dw) + "px"; box.style.top = (oy + d.y * dh) + "px"; + box.style.width = (d.w * dw) + "px"; box.style.height = (d.h * dh) + "px"; + box.style.borderColor = ckColor(d.label); + const tag = box.appendChild(document.createElement("span")); + tag.className = "tag"; tag.style.background = ckColor(d.label); + tag.textContent = `${d.label} ${(d.conf * 100).toFixed(0)}%` + + (proj ? ` · ${proj.dist.toFixed(1)}m` : ""); ov.appendChild(box); + + // world-track it: id by track_id when present, else by class+bearing + if (proj && pose) { + const id = d.track_id != null ? "t" + d.track_id + : d.label + Math.round((d.x + d.w / 2) * 8); + seen.add(id); + let o = ckObjects.get(id); + if (!o) { o = { label: d.label, hist: [] }; ckObjects.set(id, o); } + o.wx = proj.wx; o.wz = proj.wz; o.dist = proj.dist; o.last = now; + o.followed = (d === followed); + o.hist.push({ x: proj.wx, z: proj.wz, t: now }); + if (o.hist.length > 14) o.hist.shift(); + // moving vs stationary: displacement across the history window + if (o.hist.length >= 6) { + const a = o.hist[0], b = o.hist[o.hist.length - 1]; + const dt = Math.max(0.001, (b.t - a.t) / 1000); + const sp = Math.hypot(b.x - a.x, b.z - a.z) / dt; + o.moving = sp > 0.35; + o.vx = (b.x - a.x) / dt; o.vz = (b.z - a.z) / dt; + } + } + } + // age out objects we haven't seen for ~1.5s + for (const [id, o] of ckObjects) if (now - o.last > 1500) ckObjects.delete(id); + + // followed-target readout in the lower-left OSD + if (followed && pose) { + const proj = projectDetection(followed, pose, aspect); + const o = [...ckObjects.values()].find((x) => x.followed); + const motion = o && o.moving ? "MOVING" : "STATIONARY"; + $("ck-osd-bl").innerHTML = + `TRACK person · ${proj.dist.toFixed(1)} m · ${motion}`; + } else { + $("ck-osd-bl").innerHTML = `SCANNING`; } } catch {} - setTimeout(pollRobotDetections, 250); + setTimeout(pollRobotDetections, 200); } -async function ckTicker() { +/* the timeline: the robot's stream of decisions and sightings, rendered as + it happens. Same idea as the sim arena's event feed — one consistent + "what is happening" surface, whatever the source is. */ +const CK_SRC_COLOR = { follow_person_drone: "#00d47e", fix_camera: "#40a0e0", + ros: "#e0a030", frame: "#9fb0bd", camera: "#9fb0bd", system: "#6b7b88" }; +let ckTLSeen = 0; +async function ckTimeline() { try { const evs = (await (await fetch("/api/run/events")).json()).events || []; - const interesting = evs.filter((e) => - ["follow_person_drone", "fix_camera", "ros", "frame"].includes(e.source) || - (e.title && /follow|move|person|cmd/i.test(e.title))); - const last = interesting[interesting.length - 1] || evs[evs.length - 1]; - if (last) { - $("ck-tick-src").textContent = "● " + (last.source || "robot"); - $("ck-tick-msg").textContent = last.title || ""; + const list = $("ck-tl-list"); + // keep only the meaningful stream: decisions, perception, robot I/O + const rows = evs.filter((e) => + e.source !== "arena" && (e.title || "").trim() && + !/level loaded|behaviors:/.test(e.title)); + // append only what's new (events carry incrementing ids/ts) + const fresh = rows.slice(Math.max(0, rows.length - 7)); + list.innerHTML = ""; + for (const e of fresh) { + const row = document.createElement("div"); row.className = "ck-tl-row"; + const t = e.ts ? new Date(e.ts * 1000) : new Date(); + const hhmmss = t.toTimeString().slice(0, 8); + const col = CK_SRC_COLOR[e.source] || "#6b7b88"; + row.innerHTML = `${hhmmss}` + + `` + + `${(e.title || "").replace(/`; + list.appendChild(row); } } catch {} - setTimeout(ckTicker, 700); + setTimeout(ckTimeline, 600); } /* ── network robots (sim modes): a rosbridge on the wifi is a source ───── */ @@ -1475,19 +1696,25 @@ async function pollNetworkRobots() { const r = await (await fetch("/api/sources")).json(); const found = (r.network && r.network.found) || []; const chip = $("ck-net"); - if (found.length && !r.robot.connected) { + if (r.robot && r.robot.connected) { + // a robot IS connected but we're in the sim (pinned) — offer the way back + chip.textContent = "✈ robot connected · enter cockpit →"; + chip.style.display = "block"; + chip.onclick = () => { localStorage.removeItem("roborun.source"); location.reload(); }; + } else if (found.length) { const r0 = found.find((f) => f.local) || found[0]; chip.textContent = `◈ robot on network — ${r0.host}:${r0.port} · click to connect`; chip.style.display = "block"; chip.onclick = async () => { chip.textContent = "connecting…"; const res = await api("/api/ros/connect", { host: r0.host, port: r0.port }); + localStorage.removeItem("roborun.source"); if (res.ok) location.reload(); else chip.textContent = `connect failed: ${res.error || "?"}`; }; } else { chip.style.display = "none"; } } catch {} - setTimeout(pollNetworkRobots, 10000); + setTimeout(pollNetworkRobots, 8000); } function sendRobotMove(cmd) { From 17bbe46f0ad5fb97f96cd9edb5a48b36e75da68a Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 22:58:10 -0400 Subject: [PATCH 008/126] One view for everything: the cockpit is the only UI, sim is just a source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pablo's vision: the sim IS the robot's visual view — what the camera would see — so there should be one view, not a game UI and a robot UI. The stream, point map, and timeline are all derived from inputs (camera, cloud, pose) that both a sim and a ROS robot provide. The cockpit is now the universal shell, generalized to enterCockpit(src): - src=robot: the stream is the robot camera (frame-polled); map + telemetry from the ROS endpoints. - src=sim: the stream IS the 3D arena render (POV camera, full-screen behind the chrome); telemetry from the sim body; the tactical map from the sim's world-located sightings + trail; DEPLOY/HOLD run the same policy through the game's path; a LEVELS button (sim-only) picks robot + task. Both render the same HUD, tactical map (objects, range rings, moving vs stationary), timeline (decisions + sightings), and policy editor — only the source behind them changes. The game's panel-salad layout and the auto-splash are retired; LEVELS reopens the picker on demand. body.cockpit + .src-robot/ .src-sim gate the source-specific bits. --- roborun/web/arena.html | 24 ++++++-- roborun/web/arena.js | 134 +++++++++++++++++++++++++++++------------ 2 files changed, 114 insertions(+), 44 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index a4f5afa..06d7e90 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -206,14 +206,25 @@ glass HUD over it. Shown only in robot mode; the game panels + 3D arena are fully suppressed. ════════════════════════════════════════════════════════════════════ */ -body.robot-mode #toolbar, body.robot-mode #keys, body.robot-mode #toasts, -body.robot-mode .panel, body.robot-mode canvas.webgl, -body.robot-mode #start, body.robot-mode #win { display: none !important; } -body.robot-mode { background: #06080a; } +/* the cockpit is the only view: one shell for every source. A robot's + camera and a sim's 3D render are both just "the stream"; the map, + timeline and telemetry are derived from inputs both sources provide. */ +body.cockpit #toolbar, body.cockpit #keys, body.cockpit #toasts, +body.cockpit .panel, body.cockpit #start, body.cockpit #win { display: none !important; } +body.cockpit { background: #06080a; } +/* robot source: camera feed is the hero, the 3D arena canvas is unused */ +body.cockpit.src-robot canvas.webgl { display: none !important; } +/* sim source: the 3D arena render IS the stream — show it behind the chrome */ +body.cockpit.src-sim #ck-cam { display: none !important; } +body.cockpit.src-sim #ck-stage { background: transparent !important; } +body.cockpit.src-sim #ck-mapcv { } /* sim feeds the same map */ +#ck-levels { display: none; } body.cockpit.src-sim #ck-levels { display: inline-block; } #cockpit { position: fixed; inset: 0; z-index: 20; display: none; - flex-direction: column; } -body.robot-mode #cockpit { display: flex; } + flex-direction: column; pointer-events: none; } +#cockpit > * { pointer-events: auto; } +#ck-stage { pointer-events: none; } +body.cockpit #cockpit { display: flex; } /* hero camera fills the stage */ #ck-stage { position: absolute; inset: 0; overflow: hidden; background: @@ -405,6 +416,7 @@
HDG
+ - ⊟ DECK
From 7e4022cd02894b3d0089dcaa7cc5cc43835669bf Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 12 Jun 2026 23:49:57 -0400 Subject: [PATCH 011/126] One route, not three: /deck and /arena 301-redirect to / Serving the same page at three paths still read as three things. Now '/' is the one canonical URL for the cockpit; /deck and /arena redirect there. --- roborun/server.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/roborun/server.py b/roborun/server.py index f1fcd9e..22ed17d 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -106,11 +106,14 @@ def do_GET(self) -> None: if dispatch_get(self.path, self): return - # One view: the cockpit. Every entry point lands on it — a connected - # robot shows its camera/map/timeline, no robot shows the sim, and - # the SOURCE picker switches between them. (/deck kept as an alias so - # old links don't 404; the legacy flight deck is retired.) - if path_only in ("/", "/deck", "/arena"): + # One canonical view at one URL: "/". The old paths just redirect + # there so nothing 404s, but there's a single route, not three. + if path_only in ("/deck", "/arena"): + self.send_response(301) + self.send_header("Location", "/") + self.end_headers() + return + if path_only == "/": self.path = "/arena.html" super().do_GET() From 5134339a54b6eea96c591367e2036fbca23aaa55 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 00:05:11 -0400 Subject: [PATCH 012/126] Cockpit UX fixes: unblock buttons, calm sim, source modal, expandable editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From live use: - the 'enter cockpit' chip overlapped the top-bar actions (SOURCE/POLICY/ HOLD) — moved it below the bar so nothing is blocked. - the sim immediately span a dog in circles: the starter policy auto-ran. The sim now starts PAUSED (simArmed=false → policy holds); DEPLOY arms it, STOP/HOLD pauses. Calm on arrival, you choose when it moves. - the source picker was a cramped dropdown — now a centered modal with a backdrop and large source cards (Esc / backdrop / ✕ to close). - the policy editor clipped long lines and was too narrow to code in — added an expand toggle (⤢) that widens it to 80vw with a bigger font. --- roborun/web/arena.html | 55 +++++++++++++++++++++++++----------------- roborun/web/arena.js | 27 +++++++++++++++------ 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index d37df51..759cd25 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -320,6 +320,9 @@ -webkit-backdrop-filter: blur(18px); border: 1px solid rgba(42,58,70,.6); border-radius: 14px; overflow: hidden; box-shadow: 0 18px 50px rgba(0,0,0,.6); } #ck-policy.open { transform: translateX(0); } +/* expand: a roomy editor that's actually comfortable to code in */ +#ck-policy.wide { width: min(1000px, 80vw); } +#ck-policy.wide #ck-hl, #ck-policy.wide #ck-code { font-size: 13.5px; line-height: 1.7; } #ck-policy .hd { display: flex; align-items: center; gap: 10px; padding: 13px 16px; flex: none; background: rgba(16,22,28,.7); border-bottom: 1px solid #1f2a33; } #ck-policy .hd b { color: #00d47e; letter-spacing: .14em; font-size: 12px; } @@ -370,32 +373,38 @@ .ck-tl-row .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; align-self: center; } .ck-tl-row .m { color: #9fb0bd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* source picker dropdown */ -#ck-sources { position: absolute; z-index: 40; right: 16px; top: 60px; width: 320px; - display: none; background: rgba(10,14,18,.96); backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(42,58,70,.6); - border-radius: 13px; box-shadow: 0 14px 44px rgba(0,0,0,.55); overflow: hidden; } -#ck-sources.open { display: block; } -#ck-sources .hd { padding: 11px 15px 7px; font-size: 9.5px; letter-spacing: .16em; - color: #6b7b88; border-bottom: 1px solid rgba(31,42,51,.7); } -#ck-sources .ft { padding: 9px 15px; font-size: 10.5px; color: #5a6b78; +/* source picker — a centered modal, not a cramped dropdown */ +#ck-sources { position: fixed; inset: 0; z-index: 90; display: none; + align-items: center; justify-content: center; + background: rgba(4,6,8,.62); backdrop-filter: blur(5px); } +#ck-sources.open { display: flex; } +#ck-sources .box { width: 480px; max-width: 92vw; background: rgba(11,15,19,.98); + border: 1px solid rgba(42,58,70,.6); border-radius: 16px; overflow: hidden; + box-shadow: 0 30px 80px rgba(0,0,0,.7); } +#ck-sources .hd { display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; font-size: 11px; letter-spacing: .16em; color: #6b7b88; + border-bottom: 1px solid rgba(31,42,51,.7); } +#ck-sources .hd .cl { cursor: pointer; color: #8a96a3; font-size: 15px; width: 24px; height: 24px; + line-height: 24px; text-align: center; border-radius: 7px; border: 1px solid #2a3a46; } +#ck-sources .hd .cl:hover { color: #fff; border-color: #d84a4a; } +#ck-sources .ft { padding: 13px 20px; font-size: 11px; color: #5a6b78; border-top: 1px solid rgba(31,42,51,.6); } -.ck-source { display: flex; align-items: center; gap: 11px; padding: 11px 15px; cursor: pointer; +.ck-source { display: flex; align-items: center; gap: 15px; padding: 17px 20px; cursor: pointer; border-bottom: 1px solid rgba(31,42,51,.45); transition: background .12s; } -.ck-source:hover { background: rgba(0,212,126,.07); } -.ck-source .ic { font-size: 16px; width: 20px; text-align: center; color: #6b7b88; } +.ck-source:hover { background: rgba(0,212,126,.08); } +.ck-source .ic { font-size: 22px; width: 28px; text-align: center; color: #6b7b88; } .ck-source.active .ic, .ck-source.active .nm { color: #00d47e; } .ck-source .meta { flex: 1; min-width: 0; } -.ck-source .nm { font-size: 12.5px; color: #d7e0e8; letter-spacing: .04em; } -.ck-source .sub { font-size: 10.5px; color: #6b7b88; white-space: nowrap; +.ck-source .nm { font-size: 15px; color: #d7e0e8; letter-spacing: .06em; } +.ck-source .sub { font-size: 12px; color: #6b7b88; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ck-source .badge { font-size: 9px; letter-spacing: .12em; padding: 2px 7px; border-radius: 6px; +.ck-source .badge { font-size: 9.5px; letter-spacing: .12em; padding: 3px 9px; border-radius: 7px; flex: none; } .ck-source .badge.live { color: #00d47e; border: 1px solid rgba(0,212,126,.4); } -.ck-source .badge.go { color: #06080a; background: #00d47e; } +.ck-source .badge.go { color: #06080a; background: #00d47e; font-weight: 700; } -/* network-robot chip (sim modes): a robot on the wifi appears as a source */ -#ck-net { position: fixed; right: 16px; top: 16px; z-index: 80; padding: 8px 14px; +/* network-robot chip — BELOW the top bar so it never blocks the actions */ +#ck-net { position: fixed; right: 16px; top: 76px; z-index: 29; padding: 8px 14px; border-radius: 11px; font-size: 11px; letter-spacing: .04em; cursor: pointer; background: rgba(8,12,15,.85); backdrop-filter: blur(12px); color: #00d47e; border: 1px solid rgba(0,212,126,.4); box-shadow: 0 6px 22px rgba(0,0,0,.4); @@ -438,9 +447,11 @@
-
SOURCE — what you're controlling
-
-
a robot on your wifi appears here automatically
+
+
SOURCE — WHAT YOU'RE CONTROLLING
+
+
a robot on your wifi appears here automatically
+
@@ -449,7 +460,7 @@
-
BEHAVIOR
+
BEHAVIOR
diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 3bdb7c4..12fdf1d 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1310,6 +1310,8 @@ const ckColor = (label) => CLASS_COLOR[label] || "#9fb0bd"; tactical map, timeline and telemetry are derived from inputs (camera, point cloud, pose) that BOTH sources provide. src = "robot" | "sim". */ let COCKPIT = null; +let simArmed = false; // the sim policy only drives after you DEPLOY — no + // surprise dog spinning in circles on arrival function enterRobotMode() { enterCockpit("robot"); } function enterSimCockpit() { enterCockpit("sim"); } @@ -1338,14 +1340,17 @@ function enterCockpit(src) { const pol = $("ck-policy"); $("ck-policy-btn").addEventListener("click", () => pol.classList.toggle("open")); $("ck-policy-close").addEventListener("click", () => pol.classList.remove("open")); + $("ck-policy-expand").addEventListener("click", () => pol.classList.toggle("wide")); $("ck-hold").addEventListener("click", ckStop); $("ck-deploy").addEventListener("click", ckDeploy); $("ck-stop").addEventListener("click", ckStop); const srcBtn = $("ck-source-btn"), srcMenu = $("ck-sources"); - srcBtn.addEventListener("click", (e) => { e.stopPropagation(); - srcMenu.classList.toggle("open"); if (srcMenu.classList.contains("open")) buildSourceMenu(); }); - document.addEventListener("click", (e) => { - if (!srcMenu.contains(e.target) && e.target !== srcBtn) srcMenu.classList.remove("open"); }); + srcBtn.addEventListener("click", () => { srcMenu.classList.add("open"); buildSourceMenu(); }); + $("ck-src-close").addEventListener("click", () => srcMenu.classList.remove("open")); + srcMenu.addEventListener("click", (e) => { // click backdrop to close + if (e.target === srcMenu) srcMenu.classList.remove("open"); }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") srcMenu.classList.remove("open"); }); ckTimeline(); @@ -1376,6 +1381,7 @@ function enterCockpit(src) { $("ck-glyph").textContent = TYPE_GLYPH[bot.type] || "◈"; $("ck-code").value = getCode(); sync(); $("ck-pname").textContent = "player_policy"; + ckPStat("paused — edit the policy, then DEPLOY to run", ""); $("ck-levels").addEventListener("click", () => showStart()); pollSimCockpit(); } @@ -1521,6 +1527,7 @@ async function ckDeploy() { // the sim runs the same policy format; hand it to the game's run path setCode(source); document.getElementById("btnRun").click(); + simArmed = true; ckPStat("running in the sim — edit & DEPLOY to iterate", "ok"); return; } @@ -1537,8 +1544,8 @@ async function ckDeploy() { ckPStat(`live on the robot — DEPLOY applies edits`, "ok"); } async function ckStop() { - if (COCKPIT === "sim") { document.getElementById("btnStop").click(); - ckPStat("stopped", ""); return; } + if (COCKPIT === "sim") { simArmed = false; document.getElementById("btnStop").click(); + ckPStat("paused — robot holds; DEPLOY to run", ""); return; } const name = robotPolicyName || "robot_policy"; await api("/api/behaviors/disable", { name }).catch(() => {}); ckPStat(`${name} stopped — robot holds`, ""); @@ -1848,7 +1855,9 @@ async function pollCmd() { return; } if (MODE === "server") { - try { + if (COCKPIT === "sim" && !simArmed) { + serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 }; // holds until DEPLOY + } else try { const r = await (await fetch("/api/arena/cmd")).json(); serverCmd = r.cmd; serverAnswer = r.answer; @@ -1878,7 +1887,9 @@ async function pushState() { return; // reroute see()/move() to the sim } if (MODE === "wasm" && wasmRT) { - try { + if (COCKPIT === "sim" && !simArmed) { + serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 }; // holds until DEPLOY + } else try { const r = wasmRT.tick(currentState()); serverCmd = r.cmd; serverAnswer = r.answer; From 478f77f72797becd982b6d504dd34d7d80f47871 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 00:08:13 -0400 Subject: [PATCH 013/126] Replace native confirm() with a cockpit-styled deploy modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying to a real robot popped the browser's native confirm ('127.0.0.1 says…') — off-brand and ugly. Now it's an in-app modal matching the cockpit: amber-bordered glass on a dimmed backdrop, a clear warning, Cancel + green DEPLOY buttons (Esc/backdrop cancels). --- roborun/web/arena.html | 30 ++++++++++++++++++++++++++++++ roborun/web/arena.js | 22 +++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 759cd25..0feb828 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -403,6 +403,24 @@ .ck-source .badge.live { color: #00d47e; border: 1px solid rgba(0,212,126,.4); } .ck-source .badge.go { color: #06080a; background: #00d47e; font-weight: 700; } +/* in-app confirmation modal — replaces the native browser confirm() */ +#ck-confirm { position: fixed; inset: 0; z-index: 95; display: none; + align-items: center; justify-content: center; + background: rgba(4,6,8,.66); backdrop-filter: blur(6px); } +#ck-confirm.open { display: flex; } +#ck-confirm .box { width: 440px; max-width: 92vw; padding: 24px 26px; + background: rgba(11,15,19,.99); border: 1px solid rgba(224,160,48,.4); + border-radius: 16px; box-shadow: 0 30px 80px rgba(0,0,0,.7); } +#ck-confirm .ttl { font-size: 15px; color: #e0a030; letter-spacing: .04em; margin-bottom: 10px; } +#ck-confirm .msg { font-size: 13px; line-height: 1.6; color: #9fb0bd; margin-bottom: 22px; } +#ck-confirm .row { display: flex; gap: 11px; justify-content: flex-end; } +.ck-cbtn { background: rgba(16,22,28,.9); color: #9fb0bd; border: 1px solid #2a3a46; + border-radius: 9px; font: inherit; font-size: 12.5px; letter-spacing: .06em; + padding: 9px 20px; cursor: pointer; transition: all .14s; } +.ck-cbtn:hover { color: #d7e0e8; border-color: #3a4d5a; } +.ck-cbtn.yes { color: #06080a; background: #00d47e; border-color: #00d47e; font-weight: 700; } +.ck-cbtn.yes:hover { background: #1ee08c; } + /* network-robot chip — BELOW the top bar so it never blocks the actions */ #ck-net { position: fixed; right: 16px; top: 76px; z-index: 29; padding: 8px 14px; border-radius: 11px; font-size: 11px; letter-spacing: .04em; cursor: pointer; @@ -473,6 +491,18 @@
TIMELINElive decisions & sightings
+ + +
+
+
⚠ Deploy to the connected robot?
+
+
+ + +
+
+
diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 12fdf1d..eef7f12 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1521,6 +1521,22 @@ async function loadRobotBehavior() { function ckPStat(msg, cls) { const el = $("ck-pstat"); el.textContent = msg; el.className = "stat " + (cls || ""); } +// styled confirm — a cockpit modal instead of the native browser popup +function ckConfirm(title, msg) { + return new Promise((resolve) => { + const m = $("ck-confirm"); + $("ck-confirm-ttl").textContent = title; + $("ck-confirm-msg").textContent = msg; + m.classList.add("open"); + const done = (v) => { m.classList.remove("open"); + $("ck-confirm-yes").onclick = null; $("ck-confirm-no").onclick = null; + m.onclick = null; resolve(v); }; + $("ck-confirm-yes").onclick = () => done(true); + $("ck-confirm-no").onclick = () => done(false); + m.onclick = (e) => { if (e.target === m) done(false); }; + }); +} + async function ckDeploy() { const source = $("ck-code").value; if (COCKPIT === "sim") { @@ -1533,9 +1549,9 @@ async function ckDeploy() { } const name = robotPolicyName || "robot_policy"; if (!source.includes("@behavior")) { ckPStat("needs an @behavior function", "err"); return; } - if (!confirm(`Deploy "${name}" to the CONNECTED ROBOT?\nIt hot-reloads and moves hardware immediately.`)) { - ckPStat("deploy cancelled", ""); return; - } + const ok = await ckConfirm(`⚠ Deploy "${name}" to the CONNECTED ROBOT?`, + "It hot-reloads and starts moving real hardware immediately."); + if (!ok) { ckPStat("deploy cancelled", ""); return; } ckPStat("saving…", ""); const w = await api("/api/behaviors/write", { name, source }); if (!w.ok) { ckPStat(w.error || "write failed", "err"); return; } From d52b869fc9a7bf8c46d941a18c2a7ec5aa0a2438 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 00:18:00 -0400 Subject: [PATCH 014/126] Cockpit fixes: LEVELS picker opens, sim lidar map, clear HOLD toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LEVELS did nothing: cockpit CSS force-hid #start. Now #start.show shows in cockpit mode, so the robot+task picker (quadruped/humanoid/drone) opens. - the sim's lidar wasn't on the map and the 3D cloud sprayed the scene. The 3D cloud is now hidden in the cockpit; the sim's 36-ray lidar accumulates into the 2D tactical map (world frame) — the generated map building up. - the cryptic HOLD button is now a clear PAUSE/RESUME toggle whose label reflects state; PAUSE holds the policy, RESUME runs it. DEPLOY/STOP keep it in sync. --- roborun/web/arena.html | 5 ++++- roborun/web/arena.js | 45 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 0feb828..15938db 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -210,7 +210,10 @@ camera and a sim's 3D render are both just "the stream"; the map, timeline and telemetry are derived from inputs both sources provide. */ body.cockpit #toolbar, body.cockpit #keys, body.cockpit #toasts, -body.cockpit .panel, body.cockpit #start, body.cockpit #win { display: none !important; } +body.cockpit .panel, body.cockpit #win { display: none !important; } +/* the robot+task picker (LEVELS) must still open in cockpit mode */ +body.cockpit #start { display: none !important; } +body.cockpit #start.show { display: grid !important; } body.cockpit { background: #06080a; } /* robot source: camera feed is the hero, the 3D arena canvas is unused */ body.cockpit.src-robot canvas.webgl { display: none !important; } diff --git a/roborun/web/arena.js b/roborun/web/arena.js index eef7f12..1133e4a 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1294,6 +1294,7 @@ const ckObjects = new Map(); // track_id → world-tracked object (moving vs sta // own perception and decisions, never the server log (which, on a // robot-connected runtime, is dominated by the real robot) const simLog = []; +const simCloud = []; // accumulated sim-lidar world points (the generated map) function simEvent(source, title) { simLog.push({ source, title, ts: Date.now() / 1000 }); if (simLog.length > 40) simLog.shift(); @@ -1341,7 +1342,7 @@ function enterCockpit(src) { $("ck-policy-btn").addEventListener("click", () => pol.classList.toggle("open")); $("ck-policy-close").addEventListener("click", () => pol.classList.remove("open")); $("ck-policy-expand").addEventListener("click", () => pol.classList.toggle("wide")); - $("ck-hold").addEventListener("click", ckStop); + $("ck-hold").addEventListener("click", () => ckSetPaused(!ckPaused)); $("ck-deploy").addEventListener("click", ckDeploy); $("ck-stop").addEventListener("click", ckStop); const srcBtn = $("ck-source-btn"), srcMenu = $("ck-sources"); @@ -1369,6 +1370,7 @@ function enterCockpit(src) { $("ck-where").textContent = `${h === "127.0.0.1" ? "local" : "network"} · rosbridge ${h}`; }).catch(() => {}); loadRobotBehavior(); pollRobot(); pollRobotDetections(); + ckSetPaused(false, true); // the robot's behavior is running; label reads PAUSE } else { // stream = the sim's 3D render. POV is "what the robot sees"; the game // loop keeps rendering it full-screen behind the cockpit chrome. @@ -1381,6 +1383,8 @@ function enterCockpit(src) { $("ck-glyph").textContent = TYPE_GLYPH[bot.type] || "◈"; $("ck-code").value = getCode(); sync(); $("ck-pname").textContent = "player_policy"; + cloudOn = false; cloud.visible = false; // lidar lives in the 2D map, not sprayed on the scene + ckSetPaused(true, true); // start calm; label reads RESUME ckPStat("paused — edit the policy, then DEPLOY to run", ""); $("ck-levels").addEventListener("click", () => showStart()); pollSimCockpit(); @@ -1420,9 +1424,18 @@ function pollSimCockpit() { $("ck-osd-bl").innerHTML = ckObjects.size ? `SENSING ${ckObjects.size} object(s) in view` : `SCANNING`; + // accumulate the sim lidar into a world-frame map (the dog has a 36-ray + // scanner; [0] is dead ahead, CCW) — this is the generated map building up + for (let i = 0; i < lastLidar.length; i++) { + const r = lastLidar[i]; + if (r == null || r >= 7.9) continue; // 8m = no return + const a = bot.heading + (i / lastLidar.length) * Math.PI * 2; + simCloud.push([bot.pos.x + Math.cos(a) * r, bot.pos.z - Math.sin(a) * r, r]); + } + if (simCloud.length > 4000) simCloud.splice(0, simCloud.length - 4000); drawTacticalMap({ pose: { x: bot.pos.x, z: bot.pos.z, y: bot.alt, heading: bot.heading }, - points: [], robot_type: bot.type }); - setTimeout(pollSimCockpit, 150); + points: simCloud, lidar: lastLidar, robot_type: bot.type }); + setTimeout(pollSimCockpit, 120); } /* lightweight Python highlighter for the policy underlay. Tokenizes in one @@ -1521,6 +1534,27 @@ async function loadRobotBehavior() { function ckPStat(msg, cls) { const el = $("ck-pstat"); el.textContent = msg; el.className = "stat " + (cls || ""); } +// the HOLD button is a clear pause/resume toggle: PAUSE stops the policy +// (the robot/sim holds), RESUME runs it again. Label reflects current state. +let ckPaused = false; +function ckSetPaused(paused, silent) { + ckPaused = paused; + const btn = $("ck-hold"); + if (btn) { btn.innerHTML = paused ? "▶ RESUME" : "⏸ PAUSE"; + btn.classList.toggle("danger", !paused); } + if (silent) return; + if (COCKPIT === "sim") { + simArmed = !paused; + if (!paused) setCode($("ck-code").value); + try { document.getElementById(paused ? "btnStop" : "btnRun").click(); } catch {} + ckPStat(paused ? "paused — DEPLOY or RESUME to run" : "running in the sim", paused ? "" : "ok"); + } else { + const name = robotPolicyName || "robot_policy"; + api(paused ? "/api/behaviors/disable" : "/api/behaviors/enable", { name }).catch(() => {}); + ckPStat(paused ? `${name} paused — robot holds` : `${name} running`, paused ? "" : "ok"); + } +} + // styled confirm — a cockpit modal instead of the native browser popup function ckConfirm(title, msg) { return new Promise((resolve) => { @@ -1544,6 +1578,7 @@ async function ckDeploy() { setCode(source); document.getElementById("btnRun").click(); simArmed = true; + ckSetPaused(false, true); ckPStat("running in the sim — edit & DEPLOY to iterate", "ok"); return; } @@ -1561,10 +1596,10 @@ async function ckDeploy() { } async function ckStop() { if (COCKPIT === "sim") { simArmed = false; document.getElementById("btnStop").click(); - ckPStat("paused — robot holds; DEPLOY to run", ""); return; } + ckSetPaused(true, true); ckPStat("paused — DEPLOY to run", ""); return; } const name = robotPolicyName || "robot_policy"; await api("/api/behaviors/disable", { name }).catch(() => {}); - ckPStat(`${name} stopped — robot holds`, ""); + ckSetPaused(true, true); ckPStat(`${name} stopped — robot holds`, ""); } async function pollRobot() { From ad2339644b89c52ca0fea4b47cce9368ba8cb111 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 00:31:16 -0400 Subject: [PATCH 015/126] Sim cockpit: keep robot type label fresh after a LEVELS pick Picking a new robot (quadruped/humanoid/drone) from LEVELS rebuilt the sim but the cockpit identity stayed 'DOG'. pollSimCockpit now refreshes the type + glyph each tick, so the header reflects the robot you chose. --- roborun/web/arena.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 1133e4a..7769758 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1396,6 +1396,9 @@ function $(id) { return document.getElementById(id); } Telemetry from the sim body, the tactical map from sightings + trail. */ function pollSimCockpit() { const now = performance.now(); + // keep identity fresh — picking a new robot from LEVELS changes bot.type + $("ck-type").textContent = (LV?.robot || bot.type || "robot").toUpperCase(); + $("ck-glyph").textContent = TYPE_GLYPH[bot.type] || "◈"; // trail + speed if (ckTrail.length) { const prev = ckTrail[ckTrail.length - 1]; const sp = Math.hypot(bot.pos.x - prev.x, bot.pos.z - prev.z) / From 8ff22756f33f91a87579e5829f8aad95f1d84c89 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 00:48:36 -0400 Subject: [PATCH 016/126] =?UTF-8?q?Fix=20arena/state=20500=20+=20close=20t?= =?UTF-8?q?he=20sim=E2=86=92robot=20policy=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs the audit surfaced: 1. /api/arena/state 500'd on every push when a recording was active: the handler reassigned 'h = pose.get("heading")', shadowing the HTTP handler 'h', so the closing send_json(h, ...) got a float. Renamed to 'hd'. 2. The sim's player_policy is a server behavior. While a sim browser feeds /api/arena/state the arena is active and robot.move() drives the arena dog (correct). But once that browser closes, the arena goes inactive and the still-enabled player_policy falls through to drive the REAL robot. Now the sim disables player_policy on beforeunload (keepalive fetch) and the robot cockpit disables it on entry — so a closed/abandoned sim can't fly the drone. --- roborun/routes/arena.py | 8 ++++---- roborun/web/arena.js | 25 +++++++++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/roborun/routes/arena.py b/roborun/routes/arena.py index b991ca7..47fcec6 100644 --- a/roborun/routes/arena.py +++ b/roborun/routes/arena.py @@ -39,17 +39,17 @@ def arena_state(h, payload): if rec is not None: pose = payload.get("pose") or {} fx, fy = pose.get("x", 0.0), -pose.get("z", 0.0) - h = pose.get("heading", 0.0) + hd = pose.get("heading", 0.0) # not `h` — that's the HTTP handler alt = (pose.get("y", 0.0) if (payload.get("level") or {}).get("robot") == "drone" else 0.0) - rec.write_pose(fx, fy, alt, heading=h) + rec.write_pose(fx, fy, alt, heading=hd) dets = payload.get("detections") or [] if dets: rec.write_detections(dets, name="arena") - rec.write_detection_scene(dets, fx, fy, h) + rec.write_detection_scene(dets, fx, fy, hd) lidar = payload.get("lidar") or [] if lidar: - rec.write_scan(lidar, fx, fy, h) + rec.write_scan(lidar, fx, fy, hd) except Exception: pass send_json(h, 200, {"ok": True}) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 7769758..2de19d1 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1369,6 +1369,9 @@ function enterCockpit(src) { const h = (s.robot && s.robot.host) || "127.0.0.1"; $("ck-where").textContent = `${h === "127.0.0.1" ? "local" : "network"} · rosbridge ${h}`; }).catch(() => {}); + // a leftover sim sandbox policy (player_policy) would fall through to the + // real robot once no arena browser is feeding state — disable it on entry + api("/api/behaviors/disable", { name: "player_policy" }).catch(() => {}); loadRobotBehavior(); pollRobot(); pollRobotDetections(); ckSetPaused(false, true); // the robot's behavior is running; label reads PAUSE } else { @@ -1385,6 +1388,15 @@ function enterCockpit(src) { $("ck-pname").textContent = "player_policy"; cloudOn = false; cloud.visible = false; // lidar lives in the 2D map, not sprayed on the scene ckSetPaused(true, true); // start calm; label reads RESUME + // if this tab closes, make sure the sim's policy can't be left enabled + // and fall through to drive a real robot once the arena goes inactive + addEventListener("beforeunload", () => { + try { + fetch("/api/behaviors/disable", { method: "POST", keepalive: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "player_policy" }) }); + } catch {} + }); ckPStat("paused — edit the policy, then DEPLOY to run", ""); $("ck-levels").addEventListener("click", () => showStart()); pollSimCockpit(); @@ -1909,13 +1921,14 @@ async function pollCmd() { return; } if (MODE === "server") { - if (COCKPIT === "sim" && !simArmed) { - serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 }; // holds until DEPLOY - } else try { + try { const r = await (await fetch("/api/arena/cmd")).json(); - serverCmd = r.cmd; - serverAnswer = r.answer; - serverIntent = r.intent || null; + // keep the arena link warm even while paused; just hold the cmd at zero + if (COCKPIT === "sim" && !simArmed) { + serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 }; + } else { + serverCmd = r.cmd; serverAnswer = r.answer; serverIntent = r.intent || null; + } if (!linked) { linked = true; startAttemptRecording(); } } catch { linked = false; } } From b9a715fd581079276995fad3cb26c9ff82d4f017 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 09:19:13 -0400 Subject: [PATCH 017/126] Don't grab the laptop webcam when a robot is the source roborun autostarted the webcam (with its privacy light) on every boot to avoid a blank first screen. But if a robot is connected, the robot's camera is the source and the webcam is just an unwanted light on the user's machine. Autostart now skips the webcam when roborun connect has saved a robot; it's still available as a manual camera source. --- roborun/server.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/roborun/server.py b/roborun/server.py index 22ed17d..f69917b 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -321,6 +321,16 @@ def main() -> None: def _autostart() -> None: from roborun.events import emit time.sleep(2.0) # let a previous instance release the camera + # a connected/saved robot IS the camera source — never grab the + # laptop webcam (and its privacy light) out from under the user + try: + from roborun.connect import saved_robot + if saved_robot(): + emit("system", "server", + "autostart: a robot is the source — webcam left off") + return + except Exception: + pass why: list[str] = [] try: from roborun.routes._singletons import get_webcam From 2aa45939f437f4460067d23c5b487b47701408a9 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 11:30:51 -0400 Subject: [PATCH 018/126] Menu is the launcher: ROBORUN ARENA front door with a ROS robot card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static site (and any non-runtime page) now opens on the ROBORUN ARENA menu — the front door to the whole system — instead of dropping straight into a sim. A robot wired directly into THIS runtime still boots to its cockpit; everywhere else the menu leads. The menu gains a fourth card, ROS ROBOT — REAL HARDWARE, alongside the three sim robots. It shows the complete system whether or not anything is connected: a live robot (enter its cockpit), any rosbridge found on the LAN (one-click connect), and an IP field to connect by hand. So 'the same code drives a real robot' is a thing you can actually click, not just a tagline. Picks: a sim robot+task enters the sim cockpit; a ROS robot connects and enters the robot cockpit. --- roborun/web/arena.html | 17 ++++++++ roborun/web/arena.js | 91 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 15938db..5607292 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -177,6 +177,23 @@ .start-foot { color: #4a5a66; margin-top: 18px; } .start-foot b { color: #6b7b88; } + /* the ROS / real-robot card — the complete system, connected or not */ + .ros-card { display: flex; flex-direction: column; border-color: #1f4434 !important; } + .ros-card .ros-ic { width: 165px; height: 140px; margin: 0 auto; display: flex; + align-items: center; justify-content: center; font-size: 54px; color: #00d47e; opacity: .85; } + .ros-card .ros-sub { color: #00d47e; font-size: 10px; letter-spacing: .14em; margin-top: 2px; } + .ros-hint { color: #6b7b88; font-size: 11px; line-height: 1.5; padding: 6px 2px; } + .ros-hint b { color: #9fb0bd; } + .ros-card .tasks button .live { color: #00d47e; font-size: 9px; letter-spacing: .12em; + border: 1px solid #1f6e4c; border-radius: 5px; padding: 1px 6px; margin-left: 6px; } + .ros-connect { display: flex; gap: 6px; margin-top: 6px; } + .ros-connect input { flex: 1; min-width: 0; background: #0a0f13; color: #d7e0e8; + border: 1px solid #2a3a46; border-radius: 5px; font: inherit; font-size: 11px; padding: 6px 8px; } + .ros-connect input:focus { outline: none; border-color: #1f6e4c; } + .ros-connect button { background: #0d3325; color: #00d47e; border: 1px solid #1f6e4c; + border-radius: 5px; font: inherit; font-size: 11px; padding: 6px 12px; cursor: pointer; } + .ros-connect button:hover { background: #14241c; } + /* RUN is the one button a first-time visitor must find */ #btnRun { background: #0d3325 !important; color: #00d47e !important; border-color: #1f6e4c !important; font-weight: 700; padding: 3px 16px; } diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 2de19d1..474e4f1 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1252,10 +1252,16 @@ async function api(path, body) { let MODE = "detect"; // "server" | "wasm" let wasmRT = null, wasmLoading = false; async function detectMode() { - // a connected robot wins by default — but the user can pin a source (the - // SOURCE picker), e.g. to work in the sim while a robot stays connected + // resolve runtime discovery (sets window.ROBORUN_RUNTIME) before deciding + try { await fetch("/api/health"); } catch {} + const rt = window.ROBORUN_RUNTIME || {}; + const iAmRuntime = rt.live && !rt.remote; // this page IS the roborun server const pinned = localStorage.getItem("roborun.source"); - if (pinned !== "sim") { + + // a robot wired directly into THIS runtime boots straight to its cockpit; + // a static launcher (e.g. :8000) always opens the menu, even if a robot is + // reachable — the menu is the front door to the whole system. + if (iAmRuntime && pinned !== "sim") { try { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 1500); @@ -1264,17 +1270,21 @@ async function detectMode() { if (r.ok && (await r.json()).connected) { enterRobotMode(); return; } } catch {} } + + // otherwise: determine the sim backend now (server vs in-browser wasm) so a + // menu pick enters instantly, then show the ROBORUN ARENA menu (the launcher) try { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 1500); const r = await fetch("/api/arena/cmd", { signal: ctl.signal }); clearTimeout(t); - if (r.ok) { MODE = "server"; enterSimCockpit(); return; } + if (r.ok) MODE = "server"; } catch {} - MODE = "wasm"; - document.body.classList.add("wasm-mode"); - bootWasm(); - enterSimCockpit(); + if (MODE !== "server") { + MODE = "wasm"; document.body.classList.add("wasm-mode"); bootWasm(); + } + if (pinned === "sim") { enterSimCockpit(); return; } // user pinned the sim + showStart(); } /* ── robot mode: same arena, the world is the robot's telemetry ───────── @@ -2118,6 +2128,65 @@ function buildStartScreen() { card.addEventListener("pointerleave", () => { p.hoverTarget = 0; }); previews.push(p); } + buildRosCard(); // the real-robot path sits alongside the sim robots +} + +/* the ROS card: a complete system shows the real-robot path whether or not a + robot is connected right now. Connected → enter its cockpit; on the network + → connect; otherwise → type an IP. */ +function buildRosCard() { + const grid = document.getElementById("startGrid"); + const card = document.createElement("div"); + card.className = "bot-card ros-card"; + card.innerHTML = `
+

ROS ROBOT
REAL HARDWARE

+
a Go2, a drone, anything ROS — over rosbridge, the same policy file
+
looking for robots…
`; + grid.appendChild(card); + refreshRosCard(); +} +async function refreshRosCard() { + const tasks = document.getElementById("ros-tasks"); + if (!tasks) return; + let s = null; + try { s = await (await fetch("/api/sources")).json(); } catch {} + tasks.innerHTML = ""; + const enterRobot = () => { startEl.classList.remove("show"); enterRobotMode(); }; + if (!s) { // no runtime reachable at all + tasks.innerHTML = `
start roborun on your robot's ` + + `network — a connected robot shows up here. The same code that drives the ` + + `sim drives it.
`; + return; + } + if (s.robot && s.robot.connected) { // a robot is live + const btn = document.createElement("button"); + btn.innerHTML = `▶ ${(s.robot.type || "robot").toUpperCase()} · ${s.robot.host}` + + `LIVE`; + btn.addEventListener("click", enterRobot); + tasks.appendChild(btn); + } + for (const f of ((s.network && s.network.found) || [])) { // others on the LAN + if (s.robot && s.robot.connected && f.host === s.robot.host) continue; + const btn = document.createElement("button"); + btn.textContent = `connect ${f.host}:${f.port}`; + btn.addEventListener("click", async () => { + await api("/api/ros/connect", { host: f.host, port: f.port }); enterRobot(); }); + tasks.appendChild(btn); + } + if (!(s.robot && s.robot.connected)) { + const hint = document.createElement("div"); hint.className = "ros-hint"; + hint.innerHTML = "no robot connected — enter its IP (rosbridge :9090):"; + tasks.appendChild(hint); + } + const row = document.createElement("div"); row.className = "ros-connect"; + row.innerHTML = `` + + ``; + tasks.appendChild(row); + document.getElementById("ros-go").addEventListener("click", async () => { + const ip = document.getElementById("ros-ip").value.trim(); if (!ip) return; + const r = await api("/api/ros/connect", { host: ip, port: 9090 }); + if (r.ok) enterRobot(); + }); } let startRaf = 0, startLastT = 0; function animateStart(t) { @@ -2136,15 +2205,17 @@ function animateStart(t) { if (startEl.classList.contains("show")) startRaf = requestAnimationFrame(animateStart); } function showStart() { - if (MODE === "robot") return; // sim onboarding — never over a live robot + if (COCKPIT === "robot") return; // never over a live robot cockpit startEl.classList.add("show"); + refreshRosCard(); // keep the real-robot options current cancelAnimationFrame(startRaf); startRaf = requestAnimationFrame(animateStart); } function enterLevel(i) { startEl.classList.remove("show"); loadLevel(i); - policyStatus("starter policy loaded — edit it (or don't), then press ▶ RUN · WASD grabs the wheel anytime", "ok"); + if (COCKPIT !== "sim") enterSimCockpit(); // a sim pick enters the cockpit + policyStatus("starter loaded — edit it, then DEPLOY · WASD grabs the wheel anytime", "ok"); } buildStartScreen(); // splash shows after mode detection — a connected robot boots into its From e0c2e81ca2e431d4d804f872e931dbade1912621 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 11:55:19 -0400 Subject: [PATCH 019/126] ROS card: it's any rosbridge source, not 'real hardware' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gazebo and Isaac Sim aren't hardware but speak ROS just the same, so the real split is browser-sim vs anything-on-rosbridge — not sim vs real. The card now reads ROS · GAZEBO · ISAAC · HARDWARE: 'connect anything on rosbridge — a Gazebo or Isaac sim, or a real robot.' --- roborun/web/arena.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 474e4f1..e2ad36b 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -2139,9 +2139,9 @@ function buildRosCard() { const card = document.createElement("div"); card.className = "bot-card ros-card"; card.innerHTML = `
-

ROS ROBOT
REAL HARDWARE

-
a Go2, a drone, anything ROS — over rosbridge, the same policy file
-
looking for robots…
`; +

ROS
GAZEBO · ISAAC · HARDWARE

+
connect anything on rosbridge — a Gazebo or Isaac sim, or a real robot
+
looking for sources…
`; grid.appendChild(card); refreshRosCard(); } @@ -2153,9 +2153,9 @@ async function refreshRosCard() { tasks.innerHTML = ""; const enterRobot = () => { startEl.classList.remove("show"); enterRobotMode(); }; if (!s) { // no runtime reachable at all - tasks.innerHTML = `
start roborun on your robot's ` + - `network — a connected robot shows up here. The same code that drives the ` + - `sim drives it.
`; + tasks.innerHTML = `
start roborun, then point a ` + + `Gazebo or Isaac sim — or a real robot — at it over rosbridge. The same ` + + `policy file drives them all.
`; return; } if (s.robot && s.robot.connected) { // a robot is live @@ -2175,7 +2175,7 @@ async function refreshRosCard() { } if (!(s.robot && s.robot.connected)) { const hint = document.createElement("div"); hint.className = "ros-hint"; - hint.innerHTML = "no robot connected — enter its IP (rosbridge :9090):"; + hint.innerHTML = "nothing connected — enter a rosbridge IP (sim or robot, :9090):"; tasks.appendChild(hint); } const row = document.createElement("div"); row.className = "ros-connect"; From 9198b46fe474694125f3dcb9b762ee4d1c166ecc Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 13:12:34 -0400 Subject: [PATCH 020/126] ROS discovery: verify rosbridge, don't list every :9090 device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LAN scan did a plain TCP open of :9090, so any unrelated service on that port showed up as a 'robot to connect to' — confusing false positives. It now completes a websocket handshake (what rosbridge actually is); non-websocket :9090 services are filtered out. Result: only real rosbridge endpoints appear. Also: a connected source with no detectable robot type now reads ROSBRIDGE, not the ugly WEBCAM_ONLY. --- roborun/sources.py | 14 +++++++++++++- roborun/web/arena.js | 7 ++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/roborun/sources.py b/roborun/sources.py index 98b0aee..b863b26 100644 --- a/roborun/sources.py +++ b/roborun/sources.py @@ -33,9 +33,21 @@ def _local_subnet() -> list[str]: def _probe(host: str, port: int = ROSBRIDGE_PORT, timeout: float = 0.6) -> bool: + """True only if :port speaks the rosbridge websocket protocol — not just + any service that happens to listen on 9090. A plain TCP open lets through + every unrelated :9090 service (the source of confusing false positives); + the websocket handshake only completes against a real websocket server, + which is what rosbridge is.""" try: with socket.create_connection((host, port), timeout=timeout): - return True + pass + except Exception: + return False + try: + from websocket import create_connection + ws = create_connection(f"ws://{host}:{port}", timeout=timeout) + ws.close() + return True except Exception: return False diff --git a/roborun/web/arena.js b/roborun/web/arena.js index e2ad36b..ebbfcd1 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -2158,10 +2158,11 @@ async function refreshRosCard() { `policy file drives them all.`; return; } - if (s.robot && s.robot.connected) { // a robot is live + if (s.robot && s.robot.connected) { // a source is live on rosbridge + const ty = s.robot.type; + const label = (ty && ty !== "webcam_only") ? ty.toUpperCase() : "ROSBRIDGE"; const btn = document.createElement("button"); - btn.innerHTML = `▶ ${(s.robot.type || "robot").toUpperCase()} · ${s.robot.host}` + - `LIVE`; + btn.innerHTML = `▶ ${label} · ${s.robot.host}LIVE`; btn.addEventListener("click", enterRobot); tasks.appendChild(btn); } From 4d7083ffe88b135fc14bd6c98875040cb54bef04 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 13:22:06 -0400 Subject: [PATCH 021/126] Hide the 'enter cockpit' chip outside the sim cockpit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip is the sim cockpit's affordance to jump back to a connected robot, but pollNetworkRobots returned early in robot mode without hiding it — so it leaked into the robot cockpit, telling you to 'enter cockpit' while you were already in it. It now shows only in the sim cockpit and stays hidden in the menu and robot cockpit (which have the ROS card / are already there). --- roborun/web/arena.js | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index ebbfcd1..757ade9 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1847,27 +1847,17 @@ async function ckTimeline() { /* ── network robots (sim modes): a rosbridge on the wifi is a source ───── */ async function pollNetworkRobots() { - if (MODE === "robot") return; + const chip = $("ck-net"); + // this chip exists for ONE job: in the sim cockpit, offer to jump to a + // connected robot. The menu has the ROS card; the robot cockpit is already + // there. Anywhere but the sim cockpit, it must stay hidden. + if (COCKPIT !== "sim") { chip.style.display = "none"; setTimeout(pollNetworkRobots, 8000); return; } try { const r = await (await fetch("/api/sources")).json(); - const found = (r.network && r.network.found) || []; - const chip = $("ck-net"); if (r.robot && r.robot.connected) { - // a robot IS connected but we're in the sim (pinned) — offer the way back chip.textContent = "✈ robot connected · enter cockpit →"; chip.style.display = "block"; chip.onclick = () => { localStorage.removeItem("roborun.source"); location.reload(); }; - } else if (found.length) { - const r0 = found.find((f) => f.local) || found[0]; - chip.textContent = `◈ robot on network — ${r0.host}:${r0.port} · click to connect`; - chip.style.display = "block"; - chip.onclick = async () => { - chip.textContent = "connecting…"; - const res = await api("/api/ros/connect", { host: r0.host, port: r0.port }); - localStorage.removeItem("roborun.source"); - if (res.ok) location.reload(); - else chip.textContent = `connect failed: ${res.error || "?"}`; - }; } else { chip.style.display = "none"; } } catch {} setTimeout(pollNetworkRobots, 8000); From a40540b86e4345c38bb4162a22abba03452ff178 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 13:52:39 -0400 Subject: [PATCH 022/126] Detect wheeled/diff-drive ground robots, not just legged ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rover that drives on /cmd_vel and senses with a laser scanner — no joints, no mavros — was falling through to webcam_only. It's now recognized as a mobile ground robot (quadruped profile: cmd_vel control + lidar + camera), so its pose, lidar map and camera all light up the cockpit instead of reading as 'WEBCAM ONLY'. --- roborun/robot_types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/roborun/robot_types.py b/roborun/robot_types.py index 8cbde00..bc0b964 100644 --- a/roborun/robot_types.py +++ b/roborun/robot_types.py @@ -166,6 +166,11 @@ def detect_type( if joint_count_hint > 3: return RobotType.HUMANOID return RobotType.QUADRUPED + # a wheeled / diff-drive ground robot: drives on /cmd_vel, senses with + # a laser scanner — no joints, no mavros. Mobile ground robot, so use + # the quadruped profile (cmd_vel control + lidar + camera). + if "/cmd_vel" in topic_set and ("/scan" in topic_set or "/odom" in topic_set): + return RobotType.QUADRUPED if any(k in slug for k in ("go1", "go2", "a1", "b1", "b2", "spot")): return RobotType.QUADRUPED From cdee8b04f5b29b96b257288924d3f6aea7cb47f3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 15:48:28 -0400 Subject: [PATCH 023/126] Tactical map: occupancy grid like the old ROBOT MAP, bigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cockpit map drew accumulated lidar as a scatter of 1.5px dots — noisy and hard to read. It now bins returns into world cells and fills them (denser hits = brighter = more confidently a wall), so the swept area reads as solid structure — the built-up map the old deck's ROBOT MAP showed. Also enlarged the panel (280px, taller canvas) since the map carries real information. --- roborun/web/arena.html | 6 +++--- roborun/web/arena.js | 25 ++++++++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 5607292..79fd555 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -320,7 +320,7 @@ white-space: nowrap; font-weight: 700; letter-spacing: .04em; } /* bottom-right minimap */ -#ck-map { position: absolute; right: 16px; bottom: 64px; z-index: 26; width: 200px; +#ck-map { position: absolute; right: 16px; bottom: 64px; z-index: 26; width: 280px; background: rgba(8,12,15,.74); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border: 1px solid rgba(42,58,70,.5); border-radius: 13px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); } @@ -328,7 +328,7 @@ font-size: 9.5px; letter-spacing: .16em; color: #6b7b88; border-bottom: 1px solid rgba(31,42,51,.7); } #ck-map .hd b { color: #00d47e; } -#ck-map canvas { display: block; width: 100%; height: 168px; } +#ck-map canvas { display: block; width: 100%; height: 230px; } /* policy slide-in (the live behavior, edited in place) */ /* a framed floating panel that clears the top bar — header, editor and the @@ -494,7 +494,7 @@
TACTICALlidar · trail
- +
diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 757ade9..07775e7 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1702,11 +1702,26 @@ function drawTacticalMap(r) { ctx.fillStyle = "rgba(90,107,120,.7)"; ctx.fillText((span * i / 3).toFixed(0) + "m", cx + 2, cy - R * i / 3 + 9); } - // lidar / accumulated cloud (ground robots; drone has none) - ctx.fillStyle = "rgba(0,212,126,.4)"; - for (const pt of (r.points || [])) { - const px = cx + (pt[0] - rx) * sc, py = cy + (pt[1] - rz) * sc; - if (px >= 0 && px < W && py >= 0 && py < H) ctx.fillRect(px, py, 1.5, 1.5); + // occupancy map: bin accumulated lidar into world cells so nearby returns + // merge into solid walls — a built-up map, not a scatter of dots (the more + // hits land in a cell, the more confidently it's a wall, so the brighter) + const pts = r.points || []; + if (pts.length) { + const CELL = 0.3; // metres per cell + const cells = new Map(); + for (const pt of pts) { + const k = Math.round(pt[0] / CELL) + "," + Math.round(pt[1] / CELL); + cells.set(k, (cells.get(k) || 0) + 1); + } + const s = Math.max(2, CELL * sc); + for (const [k, n] of cells) { + const c = k.indexOf(","); + const gx = +k.slice(0, c) * CELL, gz = +k.slice(c + 1) * CELL; + const px = cx + (gx - rx) * sc, py = cy + (gz - rz) * sc; + if (px < 0 || px >= W || py < 0 || py >= H) continue; + ctx.fillStyle = `rgba(0,212,126,${Math.min(0.9, 0.3 + n * 0.14)})`; + ctx.fillRect(px - s / 2, py - s / 2, s, s); + } } // travel trail ctx.strokeStyle = "rgba(0,212,126,.5)"; ctx.lineWidth = 1.5; ctx.beginPath(); From a27a770fd0f467385cceb8160aae547d23d47edc Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 13 Jun 2026 18:44:51 -0400 Subject: [PATCH 024/126] Multi-panel deck is the live-robot view, fed by telemetry You preferred the old deck's arrangeable multi-panel layout (and its ROBOT MAP) over the camera-hero cockpit. So the connected robot now drives the same deck the sim uses, with real telemetry behind every panel: - pose places the bot in the 3D scene; the VIEW panels render it from any angle (top/chase/orbit), and WASD still grabs the wheel - lidar feeds integrateLidar -> the occupancy ROBOT MAP and the 3D point cloud (the map builds up as it drives) - a new EYES panel shows the robot camera with YOLO boxes (robot mode only) - STATUS shows pose/odometer/cmd; POLICY loads and RUN deploys the robot's own behavior (confirmed); MISSION becomes the robot identity - sim-only bits (LEVELS, RUNS) hide in robot mode Same deck, two sources. The camera-hero cockpit is retired (code dormant); enterSimCockpit is now a no-op and the sim runs in the deck directly. --- roborun/web/arena.html | 12 ++++ roborun/web/arena.js | 130 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 79fd555..e4c6454 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -535,6 +535,7 @@ +
+
+
+ +
+
+ +

ROBORUN ARENA

diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 07775e7..db8c813 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1321,10 +1321,131 @@ const ckColor = (label) => CLASS_COLOR[label] || "#9fb0bd"; tactical map, timeline and telemetry are derived from inputs (camera, point cloud, pose) that BOTH sources provide. src = "robot" | "sim". */ let COCKPIT = null; + +/* ── robot DECK mode: the connected robot drives the same multi-panel deck + the sim uses. Its pose places the bot, its lidar builds the ROBOT MAP and + the 3D world (VIEW panels render it from any angle), its camera fills the + EYES panel, its behavior lives in POLICY. Same deck, real telemetry. */ +let robotCloudSeen = 0, robotDeckOn = false; +async function enterRobotDeck() { + MODE = "robot"; robotDeckOn = true; + if (levelGroup) levelGroup.visible = false; // no sim walls + cloudOn = true; cloud.visible = true; + cloudReset(); occ.fill(0); + if (!scene.getObjectByName("robotGrid")) { + const grid = new THREE.GridHelper(120, 120, 0x1a2a38, 0x0f1820); + grid.name = "robotGrid"; grid.position.y = 0.01; scene.add(grid); + } + // sim-only toolbar bits off; robot panels on + for (const id of ["btnLevels"]) { const e = $(id); if (e) e.style.display = "none"; } + for (const sel of ['button[data-panel="p-runs"]', 'button[data-panel="p-brief"]']) { + const b = document.querySelector(sel); if (b) b.style.display = "none"; + } + const eyesBtn = $("btnEyes"); if (eyesBtn) eyesBtn.style.display = ""; + $("p-runs").classList.add("hidden"); + // repurpose MISSION → robot identity / connection + const brief = $("p-brief"); + if (brief) { + brief.querySelector(".p-head b").textContent = "ROBOT"; + const sel = brief.querySelector("#levelSel"); if (sel) sel.style.display = "none"; + $("briefTitle").textContent = "CONNECTED ROBOT"; + $("briefText").innerHTML = "live over rosbridge — its pose, lidar map, camera and " + + "behavior fill this deck. The same policy file drives the sim and this robot."; + const rooms = $("rooms"); if (rooms) rooms.innerHTML = ""; + } + // EYES camera feed (frame-polled, deterministic) + $("p-eyes").style.display = ""; + let camSrc = "robot"; + $("eyesSrc").addEventListener("change", (e) => { camSrc = e.target.value; }); + (function pump() { + const img = new Image(); + img.onload = () => { $("robotEyesImg").src = img.src; setTimeout(pump, 90); }; + img.onerror = () => setTimeout(pump, 400); + img.src = `${RT_BASE()}/api/camera/frame?source=${camSrc}&t=${Date.now()}`; + })(); + // POLICY edits the robot's running behavior + wireRobotPolicy(); + // robot type → body, then start telemetry + try { + const r = await (await fetch("/api/ros/cloud")).json(); + const ty = r.robot_type === "drone" ? "drone" + : r.robot_type === "humanoid" ? "biped" : "dog"; + if (bot.type !== ty) { bot.type = ty; buildBody(ty); } + } catch {} + if (mainCamSel) mainCamSel.value = "chase"; + pollRobotDeck(); + pollRobotEyesDets(); +} + +async function wireRobotPolicy() { + // load the running behavior into the deck's POLICY editor; RUN deploys it + try { + const r = await (await fetch("/api/behaviors")).json(); + const live = (r.behaviors || []).find((b) => b.enabled && + !["fix_camera", "heartbeat", "player_policy"].includes(b.name)); + if (live) { + robotPolicyName = live.name; + const src = await api("/api/behaviors/read", { name: live.name }); + if (src.ok) setCode(src.source); + policyStatus(`editing ${live.name} — live on the robot · RUN deploys`, "ok"); + } else policyStatus("no behavior running — write one and RUN", ""); + } catch {} +} + +async function pollRobotDeck() { + if (!robotDeckOn) return; + try { + const r = await (await fetch("/api/ros/cloud")).json(); + const p = r.pose; + if (p) { + bot.pos.x = p.x; bot.pos.z = p.z; bot.alt = (p.y ?? 0); + bot.heading = p.heading || 0; + if (bot.group) { + bot.group.position.set(bot.pos.x, bot.type === "drone" ? bot.alt : (bodySpec?.standH || 0.4), bot.pos.z); + bot.group.rotation.y = bot.heading; + } + } + // lidar → occupancy map + 3D cloud (the deck's ROBOT MAP + scene) + if (r.lidar && r.lidar.length) { lastLidar = r.lidar; integrateLidar(lastLidar); } + cloudCommit(); drawMap(); + // status panel + $("teleOdo") && ($("teleOdo").textContent = `odometer ${odo.toFixed(1)} m`); + if ($("telePose")) $("telePose").textContent = bot.type === "drone" + ? `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · alt ${bot.alt.toFixed(1)}` + : `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · θ ${bot.heading.toFixed(2)}`; + const link = $("link"); if (link) { link.textContent = `robot: ${r.robot_type || "live"}`; link.className = "link on"; } + } catch {} + setTimeout(pollRobotDeck, 120); +} + +async function pollRobotEyesDets() { + if (!robotDeckOn) return; + try { + const r = await (await fetch("/api/robot/detections")).json(); + const ov = $("robotEyesOv"); if (ov) { + ov.innerHTML = ""; + const host = ov.parentElement.getBoundingClientRect(); + const iw = r.w || 16, ih = r.h || 9; + const scale = Math.max(host.width / iw, host.height / ih); + const dw = iw * scale, dh = ih * scale, ox = (host.width - dw) / 2, oy = (host.height - dh) / 2; + for (const d of (r.detections || [])) { + const box = document.createElement("div"); + box.style.cssText = `position:absolute;border:1.5px solid #00d47e;border-radius:2px;` + + `left:${ox + d.x * dw}px;top:${oy + d.y * dh}px;width:${d.w * dw}px;height:${d.h * dh}px`; + box.innerHTML = `` + + `${d.label} ${(d.conf * 100).toFixed(0)}%`; + ov.appendChild(box); + } + } + } catch {} + setTimeout(pollRobotEyesDets, 250); +} + let simArmed = false; // the sim policy only drives after you DEPLOY — no // surprise dog spinning in circles on arrival -function enterRobotMode() { enterCockpit("robot"); } -function enterSimCockpit() { enterCockpit("sim"); } +function enterRobotMode() { enterRobotDeck(); } // robot drives the deck +function enterSimCockpit() { /* sim uses the deck directly; nothing to enter */ } function enterCockpit(src) { COCKPIT = src; @@ -2219,9 +2340,8 @@ function showStart() { } function enterLevel(i) { startEl.classList.remove("show"); - loadLevel(i); - if (COCKPIT !== "sim") enterSimCockpit(); // a sim pick enters the cockpit - policyStatus("starter loaded — edit it, then DEPLOY · WASD grabs the wheel anytime", "ok"); + loadLevel(i); // the sim runs in the deck directly (3D scene + panels) + policyStatus("starter loaded — edit it, then RUN · WASD grabs the wheel anytime", "ok"); } buildStartScreen(); // splash shows after mode detection — a connected robot boots into its From 14e307df452e750ff2344c7b061e46db02929ac4 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 15 Jun 2026 12:02:05 -0400 Subject: [PATCH 025/126] Fleet lab: swarm coordination sandbox + teaching tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-quadruped "/fleet" sandbox reachable from the robot picker, built to teach how a swarm covers ground over an imperfect radio. - Coverage sim with 4 strategies (lone wolves, gossip, claim-and-yield, one commander) under real limits: radio range, airtime, onboard memory, inbox depth, one goal at a time, lossy links. - Hover any robot to see what it knows: tiles it sensed firsthand (filled) vs. only heard from peers (outlined), with a live knowledge card. - Base station (the "main server") + data points: discover data and relay it home with greedy geographic routing (multi-hop / data-mule). - Environments: open / scattered obstacles / building with LOS-blocking walls. - CONCEPTS panel (expandable) explaining delivered vs dropped, overlap, optimisation, libp2p/gossipsub, stigmergy — plus tooltips on every metric. - "Your algorithm": live JS strategy editor + a Generate button that asks the local LLM to draft a policy (POST /api/fleet/strategy), runnable on the spot. - roborun/swarm/: the same model + strategies + base relay as runnable Python (python -m roborun.swarm), the headless twin; ships with the package. Also: - ROS card "Allow network scan to load robots" button — trips the browser's local-network permission and lists every rosbridge robot found as its own view. - ROS-connected robots reuse the exact sim deck; EYES camera docks into the layout instead of floating. - Replaced the last native confirm() (deploy-to-robot) with the styled modal. - vercel.json (cleanUrls) so /fleet resolves on the static deploy. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 + roborun/routes/fleet.py | 49 +++ roborun/server.py | 3 + roborun/swarm/README.md | 83 ++++ roborun/swarm/__init__.py | 18 + roborun/swarm/__main__.py | 3 + roborun/swarm/comms.py | 328 +++++++++++++++ roborun/swarm/runner.py | 60 +++ roborun/swarm/strategies.py | 115 ++++++ roborun/web/arena.html | 51 ++- roborun/web/arena.js | 102 ++++- roborun/web/fleet.html | 363 +++++++++++++++++ roborun/web/fleet.js | 778 ++++++++++++++++++++++++++++++++++++ roborun/web/vercel.json | 3 + 14 files changed, 1945 insertions(+), 30 deletions(-) create mode 100644 roborun/swarm/README.md create mode 100644 roborun/swarm/__init__.py create mode 100644 roborun/swarm/__main__.py create mode 100644 roborun/swarm/comms.py create mode 100644 roborun/swarm/runner.py create mode 100644 roborun/swarm/strategies.py create mode 100644 roborun/web/fleet.html create mode 100644 roborun/web/fleet.js create mode 100644 roborun/web/vercel.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b147c47..de35ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Added +- **FLEET sandbox** (`/fleet`) — a multi-quadruped teaching arena reachable from the right of the robot picker. Configure the radio (range, link reliability), the airtime each robot gets, and its onboard memory + inbox depth, then watch a fleet split a field under four coordination strategies (lone wolves, gossip, claim-and-yield, one commander). Live links, in-flight messages, dropped packets, coverage and wasted re-walks all rendered. +- Fleet **hover-to-inspect**: mouse over any robot to highlight what *it* knows — tiles it sensed firsthand (filled) vs. only heard from peers (outlined) — with a live knowledge/inbox/data card. +- Fleet **base station + data points**: discover data and relay it home with greedy geographic routing (multi-hop / data-mule), plus selectable **environments** (open / obstacles / building with line-of-sight-blocking walls). +- Fleet **CONCEPTS panel** (expandable): in-UI explanations of delivered vs. dropped, overlapping searches, optimisation, "is this libp2p?", swarm intelligence/stigmergy, and the base-station sink — plus hover tooltips on every metric. +- Fleet **bring-your-own algorithm**: a "✨ Your algorithm" strategy with a live JS editor, and a **Generate** button that asks the local runtime's LLM (`POST /api/fleet/strategy`) to draft a coordination policy from a plain-language goal — runnable on the spot against the same radio limits. +- **`roborun/swarm/`** — the comms model, four strategies, base-station relay and data points as runnable Python (`python -m roborun.swarm`), the headless twin of the sandbox; ships with the package. +- ROS card **network scan**: an "Allow network scan to load robots" button that trips the browser's local-network permission and lists every rosbridge robot found as its own one-click view. + +### Changed +- ROS-connected robots land in the **exact same multi-panel deck** as the rapier.js sim — the live camera (EYES) now docks into the deck layout where the sim shows its POV, instead of floating. +- The FLEET picker card is now a single **Open Fleet Lab** button; every knob lives inside the sandbox. +- Replaced the last native `confirm()` (deploy-to-robot) with the in-app styled modal, usable from the deck as well as the cockpit. + +### Deploy +- `vercel.json` with `cleanUrls` so `/fleet` resolves on the static Vercel build, matching the local server's route. + ## v0.9.2 — 2026-06-09 ### Fixed diff --git a/roborun/routes/fleet.py b/roborun/routes/fleet.py index 364ba2e..d910056 100644 --- a/roborun/routes/fleet.py +++ b/roborun/routes/fleet.py @@ -19,6 +19,55 @@ _blueprints_lock = threading.Lock() +# ── LLM-authored swarm strategy (for the /fleet sandbox) ───────────────────── + +_STRATEGY_SYSTEM = """You write coordination policies for a swarm of robots in a \ +2-D exploration sandbox. Output ONLY a JavaScript function body (no markdown, no \ +fences, no function wrapper). It runs ~5 times per second for EACH robot, with \ +two locals in scope: + + r — this robot. Fields: r.id, r.x, r.y (metres), r.payload (Set of data ids + it carries), r.inbox (queued messages). + H — helpers (all take r first): + H.arrived(r) -> true when the robot is free to pick a new goal + H.inbox(r) -> array of messages: {kind:'cells',cells:[...]} or + {kind:'claim',cell,by,dist} + H.learn(r, cells) -> merge map cells you heard from a peer + H.reserve(r, cell, by, dist) -> record someone's claim on a cell + H.shareMapped(r) -> broadcast cells you just sensed (costs 1 airtime) + H.broadcastClaim(r, cell) -> announce you are taking `cell` + H.claimedByOther(r, cell) -> true if a closer robot already claimed it + H.nearestUnknown(r, avoidClaims) -> nearest unmapped cell index, or -1 + H.goto(r, cell) -> commit to a cell (the robot does ONE goal at a time) + H.neighborCount(r) -> how many robots are in radio range right now + H.knows(r, cell) -> already mapped (sensed or heard)? + +Hard limits the policy must respect: a robot can only send a few messages per \ +second (airtime), only hears robots within radio range, has finite memory, and \ +moves to one goal at a time. Keep it short, defensive (handle cell === -1), and \ +fast. Return only the body.""".strip() + + +@post("/api/fleet/strategy") +def fleet_strategy(h, payload): + """Draft a swarm-coordination policy from a natural-language goal, using + whatever LLM the runtime has configured. Returns a JS function body the + /fleet sandbox compiles and runs.""" + goal = str(payload.get("goal", "")).strip() + if not goal: + raise ApiError(400, "Describe the algorithm you want") + from roborun import llm + try: + code = llm.complete(goal, system=_STRATEGY_SYSTEM, tier="smart", max_tokens=600) + except Exception as exc: + keys = llm.capabilities().get("configured_keys") or [] + hint = "" if keys else " — set ANTHROPIC_API_KEY (or OPENAI_API_KEY) and restart roborun" + raise ApiError(502, f"LLM unavailable: {exc}{hint}") + code = re.sub(r"^```[a-zA-Z]*\n?|```$", "", code.strip()).strip() + model = llm.resolve("smart")[1] + send_json(h, 200, {"ok": True, "code": code, "model": model}) + + # ── Fleet ──────────────────────────────────────────────────────────────────── def _load_fleet() -> list[dict]: diff --git a/roborun/server.py b/roborun/server.py index f69917b..dc1db0e 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -115,6 +115,9 @@ def do_GET(self) -> None: return if path_only == "/": self.path = "/arena.html" + # the fleet comms sandbox is its own page; "/fleet" is the clean URL + if path_only == "/fleet": + self.path = "/fleet.html" super().do_GET() def do_OPTIONS(self) -> None: diff --git a/roborun/swarm/README.md b/roborun/swarm/README.md new file mode 100644 index 0000000..bc17629 --- /dev/null +++ b/roborun/swarm/README.md @@ -0,0 +1,83 @@ +# Fleet communication demos + +One quadruped is the arena sandbox. A *fleet* of them is the hard part: how do +robots split a job and share what they find when the radio between them is +imperfect? This package is the runnable answer, and the exact twin of the live +sandbox at **`/fleet`** (`roborun/web/fleet.js`). + +## The assumptions (the knobs) + +Every robot is limited in the three ways a real one is: + +| knob | what it means | sandbox label | +|---|---|---| +| **radio range** | two robots exchange a message only while within this many metres | *Radio range* | +| **airtime** | messages a robot may send per second — its slice of a shared band | *Airtime* | +| **onboard memory** | map cells it can remember (oldest forgotten first) | *Onboard memory* | +| **inbox depth** | unread messages it queues while finishing its current move | *Inbox depth* | +| **link reliability** | best-case delivery odds; messages drop toward the edge of range | *Link reliability* | + +Plus the rule that ties them together: a robot does **one thing at a time** — it +commits to a goal cell and only re-tasks once it arrives. + +### Data points and the base station + +The swarm doesn't just map — it finds **data points** and relays them back to a +**base station** (the "main server" / sink). A discovered datum isn't done when +found, only when it reaches base. Robots route it there with **greedy geographic +routing**: hand it to an in-range neighbour closer to base, deliver straight to +base if it's in reach, or carry it like a **data mule** until a downhill peer +appears. Short range forces multi-hop relays — the swarm acting as one antenna. + +```python +run_episode("gossip", range_m=11, targets=10)["data_home"] # e.g. "10/10" +run_episode("auction", base=False) # no sink, find-only +``` + +### Delivered vs. dropped, and overlap + +A message is **delivered** when it lands in a neighbour's inbox; it's **dropped** +when the radio link failed (more likely near the edge of range) *or* the +receiver's inbox was full. **Overlap** ("re-walks") counts cells a second robot +re-mapped — wasted work that better coordination avoids. The live sandbox at +`/fleet` shows all of this, plus a **hover-to-inspect** view of what any single +robot knows (sensed firsthand vs. only heard over the radio), and a CONCEPTS tab +that explains the libp2p/gossipsub and stigmergy ideas behind it. + +## The strategies (`strategies.py`) + +| id | name | idea | +|---|---|---| +| `independent` | Lone wolves | no radio; map your own nearest-unknown cell | +| `gossip` | Gossip | broadcast what you just mapped; skip ground a neighbour has | +| `auction` | Claim & yield | announce a claim on your next cell, yield to a closer robot | +| `leader` | One commander | lowest-id robot in range hands out non-overlapping targets | + +## Run it + +```bash +python -m roborun.swarm # compare all four, headless +python -m roborun.swarm auction # one strategy +``` + +```python +from roborun.swarm import run_episode +run_episode("auction", count=8, range_m=10, memory=40) +``` + +## What you'll see + +Claim-and-yield finishes the map first: claiming *intent* (not just sharing the +map) de-conflicts who explores where. Plain gossip can backfire — once robots +agree on the same map they pick the same nearest frontier and clump. A single +commander is tidy until a robot drifts out of its range and goes quiet. Shrink +the radio range or starve memory/airtime and every strategy degrades — which is +the whole point. + +## On a real robot + +These are framework-free on purpose so they run in CI, but the shape is the +on-robot one: a strategy is a per-tick policy over a `radio` (send/recv with +range + airtime caps) and a `goto`. Wire `comms.Fleet` to real +rosbridge/DDS peers and the same four functions drive hardware — the same +"the file is the product" contract the single-robot behaviours use. diff --git a/roborun/swarm/__init__.py b/roborun/swarm/__init__.py new file mode 100644 index 0000000..64c20ce --- /dev/null +++ b/roborun/swarm/__init__.py @@ -0,0 +1,18 @@ +"""Fleet communication demos — coordinating many quadrupeds over an imperfect +radio, the runnable twin of the in-browser sandbox at ``/fleet``. + + from roborun.swarm import Fleet, STRATEGIES, run_episode + +The model lives in ``comms.py`` (radio range, airtime, onboard memory, one goal +at a time); the four strategies live in ``strategies.py``. Run a headless +episode with:: + + python -m roborun.swarm # compare all four strategies + python -m roborun.swarm gossip # just one, verbose +""" +from .comms import Fleet, Robot, Message, WORLD, CG, NCELL +from .strategies import STRATEGIES +from .runner import run_episode + +__all__ = ["Fleet", "Robot", "Message", "STRATEGIES", "run_episode", + "WORLD", "CG", "NCELL"] diff --git a/roborun/swarm/__main__.py b/roborun/swarm/__main__.py new file mode 100644 index 0000000..e2a4b6f --- /dev/null +++ b/roborun/swarm/__main__.py @@ -0,0 +1,3 @@ +from .runner import main + +main() diff --git a/roborun/swarm/comms.py b/roborun/swarm/comms.py new file mode 100644 index 0000000..54404cb --- /dev/null +++ b/roborun/swarm/comms.py @@ -0,0 +1,328 @@ +"""The fleet communication model — the same assumptions the browser sandbox +runs (roborun/web/fleet.js), as plain, runnable Python. + +Three things make multi-robot coordination hard, and all three are knobs here: + +* **radio range** — two robots can exchange a message only while within + ``range`` metres. Past it, they're on their own. +* **airtime** — a robot may send at most ``airtime`` messages per second + (its slice of a shared band); the rest wait. +* **onboard memory**— a robot remembers at most ``memory`` mapped cells and a + backlog of at most ``inbox`` unread messages; overflow is + dropped, not magically kept. + +And the robot can chase **one goal at a time** — it commits to a cell and only +re-tasks once it arrives. A strategy is just a policy over this model; see +``strategies.py``. This module is deliberately framework-free so it runs +headless (``python -m roborun.swarm``) and in CI. +""" +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field + +WORLD = 56.0 # metres across the square field +CG = 28 # coverage cells per side +CELL = WORLD / CG +SENSE = 3.2 # metres mapped around a robot as it walks +NCELL = CG * CG + + +def cell_xy(c: int) -> tuple[float, float]: + return (c % CG + 0.5) * CELL, (c // CG + 0.5) * CELL + + +@dataclass +class Message: + kind: str # "cells" | "claim" | "request" | "assign" + sender: int + payload: dict = field(default_factory=dict) + + +@dataclass +class Robot: + id: int + x: float + y: float + color: str = "#00d47e" + speed: float = 3.0 + heading: float = math.pi / 2 + goal: int = -1 # cell index, -1 = free + known: set = field(default_factory=set) # cells I believe are mapped + order: list = field(default_factory=list) # eviction order for memory + fresh: list = field(default_factory=list) # mapped since last broadcast + inbox: list = field(default_factory=list) + budget: float = 0.0 + is_leader: bool = False + assigned: int = -1 + sent: int = 0 + payload: set = field(default_factory=set) # data points carried, not yet at base + + # ── the robot's view of itself, used by the strategies ────────────── + def arrived(self) -> bool: + if self.goal < 0: + return True + gx, gy = cell_xy(self.goal) + return math.hypot(gx - self.x, gy - self.y) < CELL * 0.6 + + def just_mapped(self) -> list: + f, self.fresh = self.fresh, [] + return f + + def note_mapped(self, cells) -> None: + for c in (cells if hasattr(cells, "__iter__") else [cells]): + self._remember(c) + + def _remember(self, c: int, fresh: bool = False) -> None: + if c in self.known: + return + self.known.add(c) + self.order.append(c) + if fresh: + self.fresh.append(c) + + +class Fleet: + """Owns the field, the robots, and the imperfect radio between them.""" + + def __init__(self, count=6, range_m=14.0, airtime=4.0, memory=60, + inbox=8, reliability=0.85, seed=0, targets=8, base=True): + self.range = float(range_m) + self.airtime = float(airtime) + self.memory = int(memory) + self.inbox_cap = int(inbox) + self.reliability = float(reliability) + self.rng = random.Random(seed) + self.covered_by = [0] * NCELL # bitmask of robots that mapped cell + self.claims: dict[int, tuple[int, float]] = {} # cell -> (owner, dist) + self.metrics = dict(sent=0, recv=0, drop=0, redundant=0, + data_found=0, data_delivered=0) + self.t = 0.0 + # the base station (the "main server") sits at one edge; robots deploy + # beside it and must relay discovered data back to it, hop by hop + self.base = bool(base) + self.base_xy = (WORLD * 0.5, WORLD * 0.055) + self.base_delivered: set = set() + self.targets = [] # data points to find: [x, y, found] + for _ in range(int(targets)): + self.targets.append([4 + self.rng.random() * (WORLD - 8), + WORLD * 0.25 + self.rng.random() * (WORLD * 0.7), False]) + self.robots = [] + for i in range(count): + x = WORLD * 0.5 + (self.rng.random() - 0.5) * 8 + y = WORLD * 0.12 + (self.rng.random() - 0.5) * 6 + self.robots.append(Robot(id=i, x=x, y=y)) + self._sense() + + # ── coverage / memory ────────────────────────────────────────────── + def _sense(self) -> None: + rad2 = SENSE * SENSE + for r in self.robots: + for c in self._cells_near(r.x, r.y, SENSE): + bit = 1 << r.id + if not (self.covered_by[c] & bit): + if self.covered_by[c]: + self.metrics["redundant"] += 1 # someone already had it + self.covered_by[c] |= bit + r._remember(c, fresh=True) + for tid, tg in enumerate(self.targets): # discover data points in range + if (tg[0] - r.x) ** 2 + (tg[1] - r.y) ** 2 <= rad2: + if not tg[2]: + tg[2] = True + self.metrics["data_found"] += 1 + if self.base and tid not in self.base_delivered: + r.payload.add(tid) + self._evict(r) + + def _evict(self, r: Robot) -> None: + while len(r.order) > self.memory: # finite memory forgets ground + old = r.order.pop(0) + r.known.discard(old) + + @staticmethod + def _cells_near(x, y, rad): + r2 = rad * rad + gx0, gx1 = max(0, int((x - rad) / CELL)), min(CG - 1, int((x + rad) / CELL)) + gy0, gy1 = max(0, int((y - rad) / CELL)), min(CG - 1, int((y + rad) / CELL)) + out = [] + for gy in range(gy0, gy1 + 1): + for gx in range(gx0, gx1 + 1): + c = gy * CG + gx + cxx, cyy = cell_xy(c) + if (cxx - x) ** 2 + (cyy - y) ** 2 <= r2: + out.append(c) + return out + + # ── radio ────────────────────────────────────────────────────────── + def neighbors(self, r: Robot): + out = [] + for o in self.robots: + if o is r: + continue + d = math.hypot(o.x - r.x, o.y - r.y) + if d <= self.range: + out.append((o, d)) + return out + + def _deliver(self, frm: Robot, to: Robot, msg: Message, d: float) -> None: + p = self.reliability * (1 - (d / self.range) ** 2) # lossy toward the edge + if self.rng.random() >= p: + self.metrics["drop"] += 1 + return + if len(to.inbox) >= self.inbox_cap: # inbox overflow + self.metrics["drop"] += 1 + return + to.inbox.append(msg) + self.metrics["recv"] += 1 + + def broadcast(self, r: Robot, kind: str, **payload) -> bool: + if r.budget < 1: + return False + r.budget -= 1 + r.sent += 1 + self.metrics["sent"] += 1 + for o, d in self.neighbors(r): + self._deliver(r, o, Message(kind, r.id, payload), d) + return True + + def unicast(self, r: Robot, to: Robot, kind: str, **payload) -> bool: + if r.budget < 1: + return False + r.budget -= 1 + r.sent += 1 + self.metrics["sent"] += 1 + d = math.hypot(to.x - r.x, to.y - r.y) + if d <= self.range: + self._deliver(r, to, Message(kind, r.id, payload), d) + return True + + def recv(self, r: Robot, kind: str | None = None): + """Drain the inbox (limited per tick by airtime — backlog overflows).""" + proc = max(2, math.ceil(self.airtime)) + out, keep = [], [] + for m in r.inbox[:proc]: + (out if (kind is None or m.kind == kind) else keep).append(m) + r.inbox = keep + r.inbox[proc:] + return out + + # ── goal helpers used by the strategies ──────────────────────────── + def nearest_unknown(self, r: Robot, avoid_claims=False) -> int: + best, bd = -1, 1e9 + for c in range(NCELL): + if c in r.known: + continue + cxx, cyy = cell_xy(c) + d = math.hypot(cxx - r.x, cyy - r.y) + if avoid_claims: + cl = self.claims.get(c) + if cl and cl[0] != r.id and cl[1] <= d: + continue + if d < bd: + best, bd = c, d + if best < 0 and r.order: + best = r.order[self.rng.randrange(len(r.order))] + return best + + def reserve(self, cell: int, owner: int, dist: float) -> None: + cur = self.claims.get(cell) + if cur is None or dist < cur[1]: + self.claims[cell] = (owner, dist) + + def goto(self, r: Robot, cell: int) -> None: + r.goal = cell + + def leader_in_range(self, r: Robot): + for o, _ in self.neighbors(r): + if o.is_leader: + return o + return None + + # ── physics-free motion + leader election ────────────────────────── + def _elect_leaders(self) -> None: + parent = list(range(len(self.robots))) + + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + for r in self.robots: + for o in self.robots: + if o.id <= r.id: + continue + if math.hypot(o.x - r.x, o.y - r.y) <= self.range: + ra, rb = find(r.id), find(o.id) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) + for r in self.robots: + r.is_leader = find(r.id) == r.id + + def _move(self, dt: float) -> None: + for r in self.robots: + if r.goal < 0: + continue + gx, gy = cell_xy(r.goal) + dx, dy = gx - r.x, gy - r.y + d = math.hypot(dx, dy) + if d < 1e-3: + continue + want = math.atan2(dy, dx) + dh = (want - r.heading + math.pi) % (2 * math.pi) - math.pi + r.heading += max(-3 * dt, min(3 * dt, dh)) + step = min(d, r.speed * dt * max(0.2, math.cos(dh))) + r.x = min(WORLD - 0.4, max(0.4, r.x + math.cos(r.heading) * step)) + r.y = min(WORLD - 0.4, max(0.4, r.y + math.sin(r.heading) * step)) + + def relay_data(self) -> None: + """Greedy geographic routing: each robot holding data hands it to an + in-range neighbour closer to base, delivers straight to base if it's in + range, or carries it like a data mule until a downhill peer appears.""" + bx, by = self.base_xy + + def dB(n) -> float: + return math.hypot(getattr(n, "x", bx) - bx, getattr(n, "y", by) - by) + + for r in self.robots: + if not r.payload or r.budget < 1: + continue + if dB(r) <= self.range: # base in reach: upload + r.budget -= 1 + r.sent += 1 + self.metrics["sent"] += 1 + for tid in list(r.payload): + if tid not in self.base_delivered: + self.base_delivered.add(tid) + self.metrics["data_delivered"] += 1 + r.payload.clear() + continue + best, best_d = None, dB(r) # else hand off downhill + for o, _ in self.neighbors(r): + if dB(o) < best_d: + best, best_d = o, dB(o) + if best is not None: + r.budget -= 1 + r.sent += 1 + self.metrics["sent"] += 1 + best.payload |= r.payload + r.payload.clear() + + def coverage(self) -> float: + return sum(1 for m in self.covered_by if m) / NCELL + + def step(self, dt: float, policy) -> None: + """Advance the world one tick: refill airtime, run the policy on every + robot, relay data toward base, move, then sense. ``policy(fleet, robot)`` + is a strategy.""" + for r in self.robots: + r.budget = min(self.airtime, r.budget + self.airtime * dt) + if getattr(policy, "needs_leaders", False): + self._elect_leaders() + for r in self.robots: + policy(self, r) + if self.base: + self.relay_data() + self._move(dt) + self._sense() + self.t += dt diff --git a/roborun/swarm/runner.py b/roborun/swarm/runner.py new file mode 100644 index 0000000..7739359 --- /dev/null +++ b/roborun/swarm/runner.py @@ -0,0 +1,60 @@ +"""Headless episode runner — drive a Fleet under a strategy until the field is +mapped (or time runs out) and report the coverage / overlap / radio numbers. + +This is the demo: it shows, in plain numbers, what the sandbox shows visually — +that better coordination trades airtime and message drops for less wasted +re-walking, and that all of it degrades as the radio range shrinks. +""" +from __future__ import annotations + +from .comms import Fleet, NCELL +from .strategies import STRATEGIES + + +def run_episode(strategy: str, *, count=6, range_m=14.0, airtime=4.0, memory=60, + inbox=8, reliability=0.85, seed=0, targets=8, base=True, + dt=0.2, max_t=240.0) -> dict: + policy = STRATEGIES[strategy] + fleet = Fleet(count=count, range_m=range_m, airtime=airtime, memory=memory, + inbox=inbox, reliability=reliability, seed=seed, + targets=targets, base=base) + steps = int(max_t / dt) + for _ in range(steps): + fleet.step(dt, policy) + if fleet.coverage() >= 0.999 and len(fleet.base_delivered) >= len(fleet.targets): + break + m = fleet.metrics + return { + "strategy": strategy, + "time_s": round(fleet.t, 1), + "coverage": round(fleet.coverage() * 100, 1), + "redundant": m["redundant"], + "sent": m["sent"], + "delivered": m["recv"], + "dropped": m["drop"], + "loss_pct": round(m["drop"] / max(1, m["drop"] + m["recv"]) * 100, 1), + "data_home": f"{m['data_delivered']}/{len(fleet.targets)}", + } + + +def main(argv: list[str] | None = None) -> None: + import sys + argv = sys.argv[1:] if argv is None else argv + which = [argv[0]] if argv and argv[0] in STRATEGIES else list(STRATEGIES) + print(f"{'strategy':<13} {'cover':>6} {'time':>7} {'re-walks':>9} " + f"{'sent':>6} {'deliv':>6} {'dropped':>8} {'loss':>6} {'data→base':>10}") + print("-" * 80) + for name in which: + r = run_episode(name) + print(f"{r['strategy']:<13} {r['coverage']:>5}% {r['time_s']:>6}s " + f"{r['redundant']:>9} {r['sent']:>6} {r['delivered']:>6} " + f"{r['dropped']:>8} {r['loss_pct']:>5}% {r['data_home']:>10}") + print("\nclaim-and-yield finishes first: claiming *intent* de-conflicts who " + "goes where. Sharing only the map (gossip) can backfire — robots agree " + "on the same nearest frontier and clump. A lone commander is tidy but " + "fragile once a robot drifts out of radio range. All of it degrades as " + "range shrinks and airtime/memory tighten — try it live at /fleet.") + + +if __name__ == "__main__": + main() diff --git a/roborun/swarm/strategies.py b/roborun/swarm/strategies.py new file mode 100644 index 0000000..a7e5d1a --- /dev/null +++ b/roborun/swarm/strategies.py @@ -0,0 +1,115 @@ +"""The four fleet coordination strategies, one function each. + +A strategy is a policy over the comms model in ``comms.py`` — exactly what the +browser sandbox runs, and the shape a real on-robot behaviour would take once a +``robot.radio`` is wired. Each is called once per robot per tick. They differ +only in *what they say on the radio* and *how they choose the next cell*; the +range / airtime / memory limits do the rest. + +These are the JS twins of the strategy cards in roborun/web/fleet.js — keep the +two in sync if you change the coordination logic. +""" +from __future__ import annotations + +from .comms import Fleet, Robot, cell_xy +import math + + +def lone_wolves(fleet: Fleet, r: Robot) -> None: + """No radio at all. Map the nearest patch *you* haven't seen. Robust, but + two robots cheerfully re-walk the same ground — watch the overlap count.""" + if r.arrived(): + fleet.goto(r, fleet.nearest_unknown(r)) + + +def gossip(fleet: Fleet, r: Robot) -> None: + """Tell neighbours what you just mapped; believe what they tell you. Map + knowledge floods hop-by-hop, so robots stop chasing ground a neighbour + already covered. Bounded by airtime and range.""" + for m in fleet.recv(r, "cells"): + r.note_mapped(m.payload["cells"]) + fresh = r.just_mapped() + if fresh: + fleet.broadcast(r, "cells", cells=fresh[:12]) + if r.arrived(): + fleet.goto(r, fleet.nearest_unknown(r)) + + +def claim_and_yield(fleet: Fleet, r: Robot) -> None: + """A tiny market. Before committing to a cell, announce a claim with your + distance; if a closer robot already claimed it, yield and pick another. No + central server — just neighbours settling overlaps. Tightest coverage while + everyone stays linked.""" + for m in fleet.recv(r): + if m.kind == "cells": + r.note_mapped(m.payload["cells"]) + elif m.kind == "claim": + fleet.reserve(m.payload["cell"], m.sender, m.payload["dist"]) + fresh = r.just_mapped() + if fresh: + fleet.broadcast(r, "cells", cells=fresh[:12]) + if r.arrived(): + c = fleet.nearest_unknown(r, avoid_claims=True) + if c >= 0: + cx, cy = cell_xy(c) + dist = math.hypot(cx - r.x, cy - r.y) + fleet.reserve(c, r.id, dist) + fleet.broadcast(r, "claim", cell=c, dist=dist) + fleet.goto(r, c) + + +def one_commander(fleet: Fleet, r: Robot) -> None: + """The lowest-id robot in radio reach is the leader and hands out + non-overlapping targets. Centralised and tidy — but a robot that drifts out + of the leader's range gets no orders and falls back to lone-wolf.""" + for m in fleet.recv(r): + if m.kind == "cells": + r.note_mapped(m.payload["cells"]) + elif m.kind == "assign": + r.assigned = m.payload["cell"] + elif m.kind == "request" and r.is_leader: + c = _leader_pick(fleet, fleet.robots[m.sender]) + if c >= 0: + fleet.reserve(c, m.sender, 0.0) + fleet.unicast(r, fleet.robots[m.sender], "assign", cell=c) + fresh = r.just_mapped() + if fresh: + fleet.broadcast(r, "cells", cells=fresh[:12]) + if r.arrived(): + if r.is_leader: + fleet.goto(r, _leader_pick(fleet, r)) + else: + leader = fleet.leader_in_range(r) + if leader: + fleet.unicast(r, leader, "request") + tgt = r.assigned if (r.assigned >= 0 and r.assigned not in r.known) \ + else fleet.nearest_unknown(r, avoid_claims=True) + fleet.goto(r, tgt) + else: + fleet.goto(r, fleet.nearest_unknown(r)) # orphaned → on my own + + +one_commander.needs_leaders = True # tells Fleet.step to run leader election + + +def _leader_pick(fleet: Fleet, requester: Robot) -> int: + best, bd = -1, 1e9 + for c in range(len(fleet.covered_by)): + if c in requester.known: + continue + cl = fleet.claims.get(c) + if cl and cl[0] != requester.id: + continue + cx, cy = cell_xy(c) + d = math.hypot(cx - requester.x, cy - requester.y) + if d < bd: + best, bd = c, d + return best + + +STRATEGIES = { + "independent": lone_wolves, + "gossip": gossip, + "auction": claim_and_yield, + "leader": one_commander, +} diff --git a/roborun/web/arena.html b/roborun/web/arena.html index e4c6454..c1f6fba 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -143,7 +143,7 @@ background: rgba(5,8,10,.8); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: auto; } #start.show { display: grid; } - .start-card { text-align: center; max-width: 1000px; padding: 18px; + .start-card { text-align: center; max-width: 1200px; padding: 18px; position: relative; margin: 24px auto; } .start-card h1 { color: #00d47e; letter-spacing: .22em; font-size: 22px; margin: 0 0 8px; } .start-card .sub { color: #9fb0bd; margin: 0 auto 20px; max-width: 660px; line-height: 1.6; } @@ -193,6 +193,33 @@ .ros-connect button { background: #0d3325; color: #00d47e; border: 1px solid #1f6e4c; border-radius: 5px; font: inherit; font-size: 11px; padding: 6px 12px; cursor: pointer; } .ros-connect button:hover { background: #14241c; } + .ros-card .ros-scan { width: 100%; margin-top: 4px; background: #0d2633; color: #6ac0ff; + border: 1px solid #1f4a6e; border-radius: 5px; font: inherit; font-size: 11px; + padding: 7px; cursor: pointer; letter-spacing: .04em; } + .ros-card .ros-scan:hover { background: #123347; color: #9fd8ff; } + .ros-card .ros-scan:disabled { opacity: .7; cursor: default; } + + /* the FLEET card — many quadrupeds + an imperfect radio, configured here */ + .fleet-card { border-color: #1f4a6e !important; } + .fleet-card .fleet-ic { width: 165px; height: 92px; margin: 0 auto; display: flex; + align-items: center; justify-content: center; font-size: 38px; color: #40a0e0; + opacity: .9; letter-spacing: -.18em; } + .fleet-card h3 .fleet-sub { color: #40a0e0; font-size: 10px; letter-spacing: .14em; margin-top: 2px; } + .fleet-ctrls { display: flex; flex-direction: column; gap: 7px; margin: 6px 2px 2px; } + .fleet-ctrls .fc { display: flex; flex-direction: column; gap: 2px; } + .fleet-ctrls .fc .lbl { display: flex; justify-content: space-between; font-size: 10.5px; } + .fleet-ctrls .fc .lbl span:first-child { color: #9fb0bd; letter-spacing: .04em; } + .fleet-ctrls .fc .lbl b { color: #40a0e0; font-weight: 400; font-variant-numeric: tabular-nums; } + .fleet-ctrls input[type=range] { -webkit-appearance: none; appearance: none; height: 3px; + border-radius: 3px; background: #1f2a33; outline: none; } + .fleet-ctrls input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; + height: 12px; border-radius: 50%; background: #40a0e0; cursor: pointer; } + .fleet-ctrls input[type=range]::-moz-range-thumb { width: 12px; height: 12px; border: 0; + border-radius: 50%; background: #40a0e0; cursor: pointer; } + .fleet-card .fleet-go { background: #10283a; color: #40a0e0; border: 1px solid #1f4a6e; + border-radius: 5px; font: inherit; font-size: 11px; padding: 7px; cursor: pointer; + letter-spacing: .08em; margin-top: 8px; width: 100%; } + .fleet-card .fleet-go:hover { background: #16344c; color: #6ac0ff; } /* RUN is the one button a first-time visitor must find */ #btnRun { background: #0d3325 !important; color: #00d47e !important; @@ -512,15 +539,17 @@
- -
-
-
⚠ Deploy to the connected robot?
-
-
- - -
+
+ + +
+
+
⚠ Deploy to the connected robot?
+
+
+ +
@@ -617,7 +646,7 @@
- +
diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..72e6752 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,38 @@ +"""Local-runner CLI: roborun search / scenarios (no browser needed).""" +from __future__ import annotations + +import time + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + + +def test_search_cli_finds_and_prints(capsys): + from roborun.spatial_memory import SpatialMemoryStore + SpatialMemoryStore().store( + detections=[{"label": "forklift", "score": 1, "bbox": [0, 0, 1, 1]}], + x=5.0, y=3.0, ts=time.time(), robot_id="go2", source="production") + from roborun.cli import search_cli + assert search_cli(["forklift", "--by", "label"]) == 0 + out = capsys.readouterr().out + assert "forklift" in out and "go2" in out + + +def test_search_cli_no_hits(capsys): + from roborun.cli import search_cli + assert search_cli(["unicorn"]) == 0 + assert "no hits" in capsys.readouterr().out + + +def test_scenarios_cli_lists_and_runs(capsys): + from roborun.cli import scenarios_cli + assert scenarios_cli([]) == 0 + assert "smoke_pass" in capsys.readouterr().out + assert scenarios_cli(["run", "smoke_pass"]) == 0 + assert "PASSED" in capsys.readouterr().out + assert scenarios_cli(["suite", "demo"]) == 0 + assert "%" in capsys.readouterr().out From 52b6c704e68df58386eeaa7809180c73bd35178d Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:20:05 -0400 Subject: [PATCH 047/126] Lean local-runner UX: no mujoco_warp noise on scenario listing (lazy mjx import) mjx_env.installed() checks the [mjx] extra without importing it; demo_scenarios registers mjx_reach via installed() and imports mjx only when the scenario actually runs. 'roborun scenarios' is now clean output. 231 passing. --- roborun/demo_scenarios.py | 6 ++++-- roborun/mjx_env.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/roborun/demo_scenarios.py b/roborun/demo_scenarios.py index 0b6efe9..84da140 100644 --- a/roborun/demo_scenarios.py +++ b/roborun/demo_scenarios.py @@ -28,9 +28,11 @@ def threshold_gain(ctx): def _register_mjx() -> None: + # Register iff the [mjx] extra is present — but DON'T import it here (that + # triggers mujoco_warp's noisy optional-dep messages on every CLI listing). try: - from roborun.mjx_env import available - if not available(): + from roborun.mjx_env import installed + if not installed(): return except Exception: return diff --git a/roborun/mjx_env.py b/roborun/mjx_env.py index 5bdc048..4245e6b 100644 --- a/roborun/mjx_env.py +++ b/roborun/mjx_env.py @@ -14,6 +14,13 @@ from typing import Any, Callable +def installed() -> bool: + """Whether the [mjx] extra is importable — WITHOUT importing it, so the common + CLI/listing path never triggers mujoco_warp's noisy optional-dep messages.""" + import importlib.util + return all(importlib.util.find_spec(m) is not None for m in ("jax", "mujoco")) + + def available() -> bool: try: import jax # noqa: F401 From 8d6cb6fd0eaa97aae9c1cdae83cb05cd2548034a Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:22:53 -0400 Subject: [PATCH 048/126] System map: the one loop, three usage modes (front-end / local-runner / fleet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-bloat clarity doc — where every piece lives, how the modes share one loop, the contract, and the rule to compose primitives before adding subsystems. --- docs/SYSTEM_MAP.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/SYSTEM_MAP.md diff --git a/docs/SYSTEM_MAP.md b/docs/SYSTEM_MAP.md new file mode 100644 index 0000000..be039ba --- /dev/null +++ b/docs/SYSTEM_MAP.md @@ -0,0 +1,49 @@ +# RoboRun system map + +*How the pieces fit, for the three ways people use it. One loop, three modes.* + +## The one loop +``` + source ─▶ YOLO + CLIP ─▶ sealed MCAP (the black box) ─▶ live index ─▶ search/analyze + (sim/robot/cam) recorder.py spatial_memory.py session.search +``` +Everything is the same loop whether the source is a sim, a robot, or a webcam. +`PerceptionSession.for_mode("sim"|"robot"|"production")` runs it (`session.py`). + +## Three usage modes (what runs where) +| You are… | You use… | Entry points | +|---|---|---| +| **a front-end user** | the browser dashboards | `/` cockpit · `/scenarios` · `/search` · `/timeline` · `/analytics` · `/fleet` · `/run` (linked from the cockpit ▤ VIEWS menu) | +| **a local-runner operator** | the `roborun` CLI + Python handle | `roborun` (server) · `roborun connect ` · `roborun search ` · `roborun scenarios [run\|suite]` · `behaviors/*.py` with the `robot.*` handle | +| **a fleet (many robots)** | shared R2 + signed beacons | each robot runs the loop; `index/*/*.parquet` + `beacons/` shared via R2; `/analytics` fleet panel + cross-robot `/search` | + +## Where things live (the map) +- **Record everything** → `recorder.py` (MCAP channels: camera/detections/clip/pose/ + `/cmd`/telemetry/gps/cloud), `retention.py` (GC), `anchor.py` (RFC 3161 seal). +- **Perceive** → `webcam.py`/`ros_camera.py`/`synthetic_camera.py`, `cameras.py` + (multi-cam), `models.py` (YOLO+CLIP), `depth.py`/`scene_builder.py`. +- **Index + search over time** → `spatial_memory.py` (SQLite + numpy/sqlite-vec ANN), + `session.search`, `observations.py` (extract + fleet DuckDB). +- **Sim** → `simulator.py` (MuJoCo), `sim_backend.py` (sense via handle), + `mjx_env.py` (vectorized), `gz.py`/`gz_mujoco.py` (Gazebo). +- **Drive** → `behaviors.py` (`robot.*` handle, hot-reload), `transport/` + `rosbridge.py` + (ROS 1 & ROS 2), `connect.py`. +- **Evaluate** → `scenario.py` (scored runs + suites), `scenario_defs.py` (runnable + + `run_matrix`/`regression_gate`), `demo_scenarios.py`. +- **Agent** → `agent.py` (deck command bar), `ros_mcp.py` (MCP tools incl. + `recall_place`), `skills/` (GitHub-installable). +- **View / analyze** → `routes/scenarios.py`, `routes/search.py`, `run_series.py` + (`/run` synced playback), `web/*.html`. + +## The contract (why one file works everywhere) +A behavior written against `robot.see/move/lidar/pose/goto/go_to_place` runs +unchanged on the arena, MuJoCo, MJX, a gz world, and a real ROS 1/2 robot — the +primitives are schema-identical per backend (`docs/SIM_SPEC.md`, +`tests/test_contract.py`). Scale-up (MJX) and fidelity (gz/real) ride on top. + +## Keep it lean +Optional heavy deps are extras (`[vision] [sim] [ros] [fleet] [ann] [video] [mjx]`); +the core is 3 deps. The index is derived/rebuildable from the MCAP. If a feature +needs a new subsystem, first check it can't compose existing primitives (semantic +nav = recall + goto; the data layer is one Observation row). +``` From 6efe536d11b802693a86f36124191217f6ae611c Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:25:52 -0400 Subject: [PATCH 049/126] Curate datasets from search (Foxglove parity, sealed provenance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.export_dataset: search the all-time index → pull each hit's full frame from its MCAP → write images/ + labels.jsonl + dataset.json. Every image traces to a sealed run (verifiable provenance — their edge we keep). Reachable 3 ways: UI '⤓ Dataset' button, POST /api/search/export, and 'roborun dataset '. 232 passed. --- roborun/cli.py | 17 ++++++++++++++ roborun/routes/search.py | 21 +++++++++++++++++ roborun/server.py | 3 +++ roborun/session.py | 41 +++++++++++++++++++++++++++++++++ roborun/web/search.html | 9 ++++++++ tests/test_dataset_export.py | 44 ++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 tests/test_dataset_export.py diff --git a/roborun/cli.py b/roborun/cli.py index f73dbcd..cc93463 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -34,6 +34,23 @@ def search_cli(argv: list[str]) -> int: return 0 +def dataset_cli(argv: list[str]) -> int: + import argparse + p = argparse.ArgumentParser(prog="roborun dataset", + description="Curate a labeled dataset from a search.") + p.add_argument("query") + p.add_argument("out", help="output directory") + p.add_argument("--by", default="label", choices=["label", "clip", "near", "time"]) + p.add_argument("--k", type=int, default=500) + p.add_argument("--since", type=float) + a = p.parse_args(argv) + from roborun.spatial_memory import SpatialMemoryStore + from roborun.session import export_dataset + r = export_dataset(SpatialMemoryStore(), a.query, a.out, by=a.by, k=a.k, since=a.since) + print(f"wrote {r['count']} images + labels.jsonl → {r['dir']}") + return 0 + + def scenarios_cli(argv: list[str]) -> int: import roborun.demo_scenarios # noqa: F401 (register built-ins) from roborun.scenario import list_suites diff --git a/roborun/routes/search.py b/roborun/routes/search.py index 2040d33..7460a64 100644 --- a/roborun/routes/search.py +++ b/roborun/routes/search.py @@ -32,6 +32,27 @@ def search_history(h, payload): send_json(h, 200, {"ok": True, "results": rows, "total": len(rows)}) +@post("/api/search/export") +def search_export(h, payload): + """Curate a labeled dataset from a search → datasets//. Body: {query, + by?, since?, until?, name?}.""" + import time + from pathlib import Path + from roborun.session import export_dataset + try: + from roborun.routes._singletons import get_memory + store = get_memory() + except Exception: + from roborun.spatial_memory import SpatialMemoryStore + store = SpatialMemoryStore() + name = str(payload.get("name") or f"ds_{int(time.time())}") + out = Path("datasets") / name + r = export_dataset(store, payload.get("query", ""), str(out), + by=str(payload.get("by", "label")), + since=payload.get("since"), until=payload.get("until")) + send_json(h, 200, r) + + @post("/api/perception/start") def perception_start(h, payload): """Start the unified capture loop in a mode (sim|robot|production).""" diff --git a/roborun/server.py b/roborun/server.py index ea2aaa1..51664e2 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -287,6 +287,9 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "scenarios": from roborun.cli import scenarios_cli raise SystemExit(scenarios_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "dataset": + from roborun.cli import dataset_cli + raise SystemExit(dataset_cli(sys.argv[2:])) if not WEB_ROOT.exists(): raise SystemExit(f"Missing web directory at {WEB_ROOT}") STATE_ROOT.mkdir(parents=True, exist_ok=True) diff --git a/roborun/session.py b/roborun/session.py index 07ee6fb..6caae3f 100644 --- a/roborun/session.py +++ b/roborun/session.py @@ -146,6 +146,47 @@ def stop(self) -> None: pass +def export_dataset(store: Any, query: Any, out_dir: str, by: str = "label", + k: int = 500, since: float | None = None, + until: float | None = None) -> dict: + """Curate a labeled dataset from a search (Foxglove 'curate datasets to train + models', on our sealed substrate): search the all-time index, pull each hit's + full frame from its MCAP, write images/ + labels.jsonl + dataset.json. Every + image traces back to a sealed run, so the dataset's provenance is verifiable.""" + import json + import time + from pathlib import Path + from roborun.observations import get_frame + from roborun.run_series import _find_mcap + + hits = search(store, query, by=by, k=k, since=since, until=until) + out = Path(out_dir) + (out / "images").mkdir(parents=True, exist_ok=True) + manifest = [] + for i, h in enumerate(hits): + img = None + fr = h.get("frame_ref") + if fr and fr.get("run_id") and fr.get("topic") and fr.get("log_time"): + mcap = _find_mcap(fr["run_id"], h.get("robot_id")) + if mcap is not None: + img = get_frame(mcap, fr["topic"], int(fr["log_time"])) + if img is None and h.get("id"): + img = store.get_thumbnail(h["id"]) # fall back to the stored thumb + if img is None: + continue + name = f"{i:05d}.jpg" + (out / "images" / name).write_bytes(img) + manifest.append({"image": f"images/{name}", + "detections": h.get("detections"), "ts": h.get("ts"), + "robot_id": h.get("robot_id"), "source": h.get("source"), + "run_id": (fr or {}).get("run_id")}) + (out / "labels.jsonl").write_text("\n".join(json.dumps(m) for m in manifest)) + (out / "dataset.json").write_text(json.dumps( + {"query": str(query), "by": by, "count": len(manifest), + "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, indent=2)) + return {"ok": True, "count": len(manifest), "dir": str(out)} + + def search(store: Any, query: Any, by: str = "clip", k: int = 10, since: float | None = None, until: float | None = None, robot_id: str | None = None, source_id: str | None = None) -> list[dict]: diff --git a/roborun/web/search.html b/roborun/web/search.html index 1621208..81e52f0 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -61,6 +61,7 @@

🔍 SEARCH OVER TIME

+
Search across every recorded run — sim, robot, and production — over all of time.
@@ -74,6 +75,14 @@

🔍 SEARCH OVER TIME

}); const toEpoch=v=> v? Math.floor(new Date(v).getTime()/1000): undefined; +async function exportDs(){ + const q=$("#q").value.trim(); if(!q){ $("#meta").textContent="Type a query to export."; return; } + $("#meta").textContent="Curating dataset…"; + const body={query:q, by, since:toEpoch($("#since").value), until:toEpoch($("#until").value)}; + try{ const r=await (await fetch("/api/search/export",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})).json(); + $("#meta").textContent=`Dataset curated: ${r.count} images + labels.jsonl → ${r.dir}`; + }catch(e){ $("#meta").textContent="Export failed: "+e; } +} async function run(){ const body={ query:$("#q").value.trim(), by, k:60 }; const s=toEpoch($("#since").value), u=toEpoch($("#until").value); diff --git a/tests/test_dataset_export.py b/tests/test_dataset_export.py new file mode 100644 index 0000000..9324335 --- /dev/null +++ b/tests/test_dataset_export.py @@ -0,0 +1,44 @@ +"""Curate a labeled dataset from a search (Foxglove parity, sealed provenance).""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +import numpy as np + +from roborun.session import export_dataset +from roborun.spatial_memory import SpatialMemoryStore + + +def test_export_writes_images_and_labels(tmp_path): + store = SpatialMemoryStore(db_path=tmp_path / "m.db") + # frames stored with thumbnails (no MCAP needed — falls back to thumb) + frame = np.full((48, 64, 3), 120, np.uint8) + for i in range(3): + store.store(frame=frame, + detections=[{"label": "forklift", "score": 0.9, "bbox": [0, 0, 1, 1]}], + ts=time.time() + i, robot_id="r1", source="production") + store.store(frame=frame, + detections=[{"label": "person", "score": 0.9, "bbox": [0, 0, 1, 1]}], + ts=time.time(), robot_id="r1", source="production") + + out = tmp_path / "ds" + r = export_dataset(store, "forklift", str(out), by="label", k=10) + assert r["ok"] and r["count"] == 3 # only the forklifts + assert (out / "dataset.json").exists() + imgs = list((out / "images").glob("*.jpg")) + assert len(imgs) == 3 + lines = (out / "labels.jsonl").read_text().strip().splitlines() + assert len(lines) == 3 + rec = json.loads(lines[0]) + assert rec["detections"][0]["label"] == "forklift" + assert rec["image"].startswith("images/") + meta = json.loads((out / "dataset.json").read_text()) + assert meta["query"] == "forklift" and meta["count"] == 3 + + +def test_export_empty_query(tmp_path): + store = SpatialMemoryStore(db_path=tmp_path / "m.db") + r = export_dataset(store, "nothing", str(tmp_path / "ds"), by="label") + assert r["ok"] and r["count"] == 0 From 0be5efeb5bc3b3eb025b7d126615d7cdaf961937 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:29:03 -0400 Subject: [PATCH 050/126] End-to-end system test + source-mode coherence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_e2e_system: record→index→search→scenario→analytics→dataset in one flow (no hardware) — proves the pieces are one system. Fix: StreamingExtractor tags the live index with the real mode (sim/robot/production), so the analytics source breakdown is accurate. 233 passed. --- roborun/observations.py | 5 ++- roborun/session.py | 4 ++ tests/test_e2e_system.py | 83 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/test_e2e_system.py diff --git a/roborun/observations.py b/roborun/observations.py index 27ccda0..a63fad3 100644 --- a/roborun/observations.py +++ b/roborun/observations.py @@ -102,11 +102,12 @@ class StreamingExtractor: and makes search live during a run.""" def __init__(self, store, robot_id: str = "local", run_id: str | None = None, - join_tol: float = JOIN_TOLERANCE) -> None: + join_tol: float = JOIN_TOLERANCE, source: str = "stream") -> None: self.store = store self.robot_id = robot_id self.run_id = run_id self.join_tol = join_tol + self.source = source # mode tag (sim/robot/production) for the index self._det: tuple[float, list] | None = None self._clip: tuple[float, Any] | None = None self._pose: tuple[float, tuple] | None = None @@ -131,7 +132,7 @@ def on_camera(self, ts: float, topic: str, source_id: str | None = None) -> None self.store.store(frame=None, embedding=emb, detections=dets, x=x, y=y, z=z, robot_id=self.robot_id, ts=ts, run_id=self.run_id, frame_topic=topic, - frame_log_time=int(ts * 1e9), source="stream", + frame_log_time=int(ts * 1e9), source=self.source, source_id=source_id) self.inserted += 1 diff --git a/roborun/session.py b/roborun/session.py index 6caae3f..4b65a3c 100644 --- a/roborun/session.py +++ b/roborun/session.py @@ -72,6 +72,10 @@ def __init__(self, source: Any, store: Any, robot_id: str = "local", self._stop = threading.Event() self._t: threading.Thread | None = None self.indexed = 0 + # so the live index tags observations with the real mode (sim/robot/production) + ext = getattr(recorder, "extractor", None) + if ext is not None: + ext.source = mode @classmethod def for_mode(cls, mode: str, store: Any = None, source_id: str = "cam", diff --git a/tests/test_e2e_system.py b/tests/test_e2e_system.py new file mode 100644 index 0000000..af25cd0 --- /dev/null +++ b/tests/test_e2e_system.py @@ -0,0 +1,83 @@ +"""End-to-end: the whole system as ONE loop, no hardware. + +sim/synthetic source → YOLO+CLIP(fake) → sealed MCAP → live index → search over time +→ scenario score → analytics → curate a dataset. Proves the pieces compose into the +single system the user described (sim/robot/production, track+search over time). +""" +from __future__ import annotations + +import time + +import numpy as np +import pytest + +cv2 = pytest.importorskip("cv2") + + +@pytest.fixture() +def env(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + from roborun.routes import _singletons + _singletons._spatial_memory = None + return tmp_path + + +def _fake_embed(frame): + return frame.reshape(-1, 3).mean(axis=0).astype(np.float32) + + +def test_full_loop(env): + from roborun.synthetic_camera import SyntheticCamera + from roborun.session import PerceptionSession, search, export_dataset + from roborun.routes._singletons import get_memory + from roborun.recorder import RunRecorder + from roborun.observations import StreamingExtractor + from roborun.events import runs_root + + store = get_memory() + + # 1) RECORD + INDEX: a synthetic "production" camera through the unified session, + # writing to a sealed MCAP that streams into the searchable index. + rec = RunRecorder(robot_id="dock-bot", root=runs_root(), checkpoint_interval=0.05) + rec.extractor = StreamingExtractor(store, robot_id="dock-bot", run_id=rec.run_id) + cam = SyntheticCamera(label="person") + sess = PerceptionSession(cam, store, mode="production", source_id="dock", + recorder=rec, embed_fn=_fake_embed, hz=30) + cam.start() + try: + for _ in range(8): + sess.tick() + time.sleep(0.02) + finally: + cam.stop() + seal = rec.close(do_anchor=False) + + # the run is sealed + verifiable, and carries camera + detections + clip + assert seal["merkle_root"] + assert seal["message_counts"].get("/camera/dock") + assert seal["message_counts"].get("/clip/embeddings") + + # 2) SEARCH OVER TIME across all history, by label and semantic + hits = search(store, "person", by="label") + assert hits and hits[0]["source_id"] == "dock" + assert hits[0]["source"] == "production" # live index tagged with the real mode + recent = search(store, "person", by="label", since=time.time() - 3600) + assert len(recent) == len(hits) + + # 3) SCORE a scenario (the Define/Analyze surface) and see it aggregate + from roborun import scenario as S + with S.scenario("dock_patrol", suite="ops", tags=["nav"], seed=1) as run: + run.metric("sightings", len(hits)) + run.evaluate("coverage", seen=1.0) + run.passed() + assert S.list_suites()[0]["pass_rate"] == 1.0 + + # 4) ANALYTICS reflects everything tracked + assert store.label_histogram()[0]["label"] == "person" + robots = {r["robot_id"] for r in store.robots_breakdown()} + assert "dock-bot" in robots + + # 5) CURATE a dataset from the search — sealed provenance + out = env / "dataset" + ds = export_dataset(store, "person", str(out), by="label") + assert ds["count"] >= 1 and (out / "labels.jsonl").exists() From 026e612b688a53740a676d21b113fd7418de02eb Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:32:58 -0400 Subject: [PATCH 051/126] Incidents: flag a moment in a run to revisit (data-flywheel primitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research (Foxglove $40M Series B + Unified Search/Curation launch) frames 'incidents to revisit' as flywheel raw material. incidents.flag/list — a {run_id,ts,note,tag} bookmark stored as a JSONL sidecar, emitted to the timeline, surfaced on /run (⚑ Flag at the scrub moment), and 'roborun flag '. Lean: composes events+runs, no new store. 235 passed. --- roborun/incidents.py | 57 +++++++++++++++++++++++++++++++++++++++++ roborun/routes/run.py | 25 ++++++++++++++++++ roborun/server.py | 9 +++++++ roborun/web/run.html | 19 +++++++++++++- tests/test_incidents.py | 29 +++++++++++++++++++++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 roborun/incidents.py create mode 100644 tests/test_incidents.py diff --git a/roborun/incidents.py b/roborun/incidents.py new file mode 100644 index 0000000..c73b619 --- /dev/null +++ b/roborun/incidents.py @@ -0,0 +1,57 @@ +"""Incidents — flag a moment in a run to revisit (the data-flywheel primitive). + +The winning physical-AI loop turns production data into curated "incidents to +revisit" (Foxglove's framing). An incident is just a `{run_id, ts, note, tag}` +bookmark — stored as a small JSONL sidecar next to the runs, emitted onto the +event timeline, and surfaced on the /run player. Tiny, composes existing infra, +no new store. Search/curation already cover the heavy lifting. +""" +from __future__ import annotations + +import json +import time +import uuid +from typing import Any + +from roborun.events import emit, runs_root + + +def _path(): + p = runs_root() / "incidents.jsonl" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def flag(run_id: str, ts: float | None = None, note: str = "", + tag: str = "incident", robot_id: str | None = None) -> dict[str, Any]: + """Bookmark a moment. `ts` defaults to now (live flagging). Returns the record.""" + rec = {"id": uuid.uuid4().hex[:12], "run_id": run_id, + "ts": ts if ts is not None else time.time(), + "note": note, "tag": tag, "robot_id": robot_id, + "flagged_at": time.time()} + with open(_path(), "a") as fh: + fh.write(json.dumps(rec) + "\n") + emit("incident", robot_id or "operator", + f"flagged · {tag}" + (f" · {note}" if note else ""), + {"run_id": run_id, "ts": rec["ts"], "incident_id": rec["id"]}) + return rec + + +def list_incidents(run_id: str | None = None, tag: str | None = None, + limit: int = 200) -> list[dict[str, Any]]: + p = _path() + if not p.exists(): + return [] + out: list[dict] = [] + for line in p.read_text().splitlines(): + try: + r = json.loads(line) + except Exception: + continue + if run_id and r.get("run_id") != run_id: + continue + if tag and r.get("tag") != tag: + continue + out.append(r) + out.sort(key=lambda r: r.get("flagged_at", 0), reverse=True) + return out[:limit] diff --git a/roborun/routes/run.py b/roborun/routes/run.py index e0ba671..e315563 100644 --- a/roborun/routes/run.py +++ b/roborun/routes/run.py @@ -45,6 +45,31 @@ def run_series_route(h): send_json(h, 200, run_series(run_id, robot)) +@post("/api/incidents/flag") +def incident_flag(h, payload): + """Flag a moment in a run to revisit. Body: {run_id, ts?, note?, tag?}.""" + from roborun.incidents import flag + run_id = str(payload.get("run_id", "")).strip() + if not run_id: + send_json(h, 400, {"ok": False, "error": "run_id required"}) + return + rec = flag(run_id, ts=payload.get("ts"), note=str(payload.get("note", "")), + tag=str(payload.get("tag", "incident")), + robot_id=payload.get("robot_id")) + send_json(h, 200, {"ok": True, "incident": rec}) + + +@get("/api/incidents") +def incidents_list(h): + """List incidents, optional ?run=&tag=.""" + from urllib.parse import parse_qs, urlparse + from roborun.incidents import list_incidents + q = parse_qs(urlparse(h.path).query) + rows = list_incidents(run_id=(q.get("run") or [None])[0], + tag=(q.get("tag") or [None])[0]) + send_json(h, 200, {"ok": True, "incidents": rows, "total": len(rows)}) + + @get("/api/run/frame") def run_frame(h): """Synced-playback frame: the camera JPEG nearest ?t= in run ?id=.""" diff --git a/roborun/server.py b/roborun/server.py index 51664e2..8fc9c7f 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -290,6 +290,15 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "dataset": from roborun.cli import dataset_cli raise SystemExit(dataset_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "flag": + from roborun.incidents import flag, list_incidents + if len(sys.argv) > 2: + r = flag(sys.argv[2], note=" ".join(sys.argv[3:])) + print(f"flagged {r['id']} on {r['run_id']}") + else: + for i in list_incidents(): + print(f" {i['run_id']} ⚑ {i.get('note') or i['tag']}") + raise SystemExit(0) if not WEB_ROOT.exists(): raise SystemExit(f"Missing web directory at {WEB_ROOT}") STATE_ROOT.mkdir(parents=True, exist_ok=True) diff --git a/roborun/web/run.html b/roborun/web/run.html index 3fea1cd..696830f 100644 --- a/roborun/web/run.html +++ b/roborun/web/run.html @@ -30,9 +30,12 @@

RUN

loading…

Robot trajectory

@@ -100,6 +103,20 @@

Synced playback

}; scrub.oninput=()=>{ clearTimeout(timer); timer=setTimeout(show, 60); }; scrub.value=0; show(); + let curT=t0; + scrub.addEventListener("input",()=>{ curT=t0+(t1-t0)*scrub.value/1000; }); + $("#flag").onclick=async()=>{ + const note=prompt("Note for this moment (optional):")||""; + await fetch("/api/incidents/flag",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({run_id:qid,ts:curT,note})}); + loadIncidents(); + }; + loadIncidents(); +} +async function loadIncidents(){ + try{ const d=await (await fetch("/api/incidents?run="+encodeURIComponent(qid))).json(); + const el=$("#incidents"); + el.innerHTML=(d.incidents||[]).map(i=>`⚑ ${i.note||i.tag}`).join(""); + }catch(e){} } load(); diff --git a/tests/test_incidents.py b/tests/test_incidents.py new file mode 100644 index 0000000..69824b8 --- /dev/null +++ b/tests/test_incidents.py @@ -0,0 +1,29 @@ +"""Incidents — flag a moment in a run to revisit (data-flywheel primitive).""" +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + + +def test_flag_and_list(): + from roborun.incidents import flag, list_incidents + r = flag("run_123", ts=42.0, note="robot clipped a pallet", tag="near-miss", + robot_id="go2") + assert r["run_id"] == "run_123" and r["note"] + rows = list_incidents() + assert len(rows) == 1 and rows[0]["tag"] == "near-miss" + assert list_incidents(run_id="run_123")[0]["ts"] == 42.0 + assert list_incidents(run_id="other") == [] + assert list_incidents(tag="near-miss") and list_incidents(tag="nope") == [] + + +def test_flag_emits_timeline_event(): + from roborun.incidents import flag + from roborun.events import recent + flag("run_x", note="check this") + titles = [e.get("title", "") for e in recent(20)] + assert any("flagged" in t for t in titles) From 790125bd6965c3f00bfdc61b78b88f28a5f6554a Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:36:25 -0400 Subject: [PATCH 052/126] First-run experience: 'roborun demo' seeds a populated, searchable UI Records 3 synthetic runs (camera+detections+pose, indexed) + scores the demo suite, so a fresh install shows live dashboards immediately instead of empty pages. Serves the front-end + 'just works' first-run. 236 passed. --- roborun/cli.py | 38 ++++++++++++++++++++++++++++++++++++++ roborun/server.py | 3 +++ tests/test_cli.py | 10 ++++++++++ 3 files changed, 51 insertions(+) diff --git a/roborun/cli.py b/roborun/cli.py index cc93463..736d33b 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -34,6 +34,44 @@ def search_cli(argv: list[str]) -> int: return 0 +def demo_cli(argv: list[str]) -> int: + """Seed a populated demo so a fresh install shows a live UI immediately: + a recorded synthetic run (camera+detections+pose, indexed + searchable) and a + scored scenario suite. `roborun demo` then open the dashboards.""" + import time + import numpy as np + from roborun.recorder import RunRecorder + from roborun.events import runs_root + from roborun.observations import StreamingExtractor + from roborun.spatial_memory import SpatialMemoryStore + from roborun.synthetic_camera import SyntheticCamera + from roborun.session import PerceptionSession + import roborun.demo_scenarios # noqa + from roborun.scenario_defs import run_suite + + store = SpatialMemoryStore() + labels = ["person", "forklift", "pallet"] + for lab in labels: + rec = RunRecorder(robot_id=f"demo-{lab}", root=runs_root(), checkpoint_interval=0.05) + rec.extractor = StreamingExtractor(store, robot_id=f"demo-{lab}", + run_id=rec.run_id, source="production") + cam = SyntheticCamera(label=lab) + sess = PerceptionSession(cam, store, mode="production", source_id=f"{lab}-cam", + recorder=rec, embed_fn=lambda f: f.reshape(-1, 3).mean(0).astype(np.float32), + hz=30) + cam.start() + try: + for _ in range(10): + sess.tick(); time.sleep(0.01) + finally: + cam.stop() + rec.close(do_anchor=False) + run_suite("demo") + print("Demo seeded: 3 runs recorded + indexed, demo suite scored.") + print("Open http://localhost:8765 → ▤ VIEWS → Search / Analytics / Scenarios") + return 0 + + def dataset_cli(argv: list[str]) -> int: import argparse p = argparse.ArgumentParser(prog="roborun dataset", diff --git a/roborun/server.py b/roborun/server.py index 8fc9c7f..001380d 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -290,6 +290,9 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "dataset": from roborun.cli import dataset_cli raise SystemExit(dataset_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "demo": + from roborun.cli import demo_cli + raise SystemExit(demo_cli(sys.argv[2:])) if len(sys.argv) > 1 and sys.argv[1] == "flag": from roborun.incidents import flag, list_incidents if len(sys.argv) > 2: diff --git a/tests/test_cli.py b/tests/test_cli.py index 72e6752..51e970e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,3 +36,13 @@ def test_scenarios_cli_lists_and_runs(capsys): assert "PASSED" in capsys.readouterr().out assert scenarios_cli(["suite", "demo"]) == 0 assert "%" in capsys.readouterr().out + + +def test_demo_cli_populates(capsys): + from roborun.cli import demo_cli + assert demo_cli([]) == 0 + assert "Demo seeded" in capsys.readouterr().out + from roborun.spatial_memory import SpatialMemoryStore + from roborun.scenario import list_suites + assert SpatialMemoryStore().stats()["total"] > 0 + assert any(s["suite"] == "demo" for s in list_suites()) From 4030c6ff31c13810528f522ee515ba0cd1d8adca Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:37:34 -0400 Subject: [PATCH 053/126] README: document track-and-search-over-time, the dashboards, roborun demo, ROS 1+2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front-door discoverability for the new surfaces — search/scenarios/run/analytics/ timeline, the local-runner verbs (search/scenarios/dataset), semantic nav, and explicit ROS 1 + ROS 2 support. --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1cfff8e..6d4b121 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,28 @@ What this proves: the recorded run — images, detections, and decisions include The UI at `http://localhost:8765` is the flight deck itself: live camera with YOLO boxes, the black box streaming, the live anchor badge, a command bar, and director keys. `M` record/seal · `V` verify · `T` tamper · `R` runs/replay · `C` sources. +## Track and search everything over time + +Every run — sim, real robot, or webcam — flows through one loop: **YOLO + CLIP → sealed MCAP → a live index you can search across all of history.** So "where did I last see the forklift", "who was in the lobby yesterday", "every red mug across the fleet" are one query — semantic (CLIP), label (YOLO), place, or time window — over every run and robot. + +The cockpit's **▤ VIEWS** menu opens the dashboards (also `roborun demo` to populate them instantly): + +- **/search** — find anything/anyone over time; export the hits as a labeled dataset (sealed provenance). +- **/scenarios** — scored runs + suites with pass-rates; run a scenario or a whole suite (the Antioch loop). +- **/run** — per-run trajectory · velocity · clearance · LiDAR, with **synced playback** (scrub a moment → the frame the robot saw) and **⚑ Flag** to bookmark incidents to revisit. +- **/analytics** — detections over time, suite pass-rates, per-robot fleet activity. +- **/timeline** — the live event stream + recent sightings. + +From the local runner, no browser needed: + +```bash +roborun search "person" # across every recorded run, all-time +roborun scenarios run mjx_reach # score a scenario (vectorized MuJoCo, sealed) +roborun dataset "forklift" ./ds # curate a labeled training set from a search +``` + +The robot handle gets it too: `robot.go_to_place("the charging dock")` navigates to where it last saw something (semantic memory), and the same `recall_place` is an MCP tool any agent can call. + ## Connect a real robot ```bash @@ -109,7 +131,7 @@ roborun connect 192.168.1.42 --move # proves it: clamped 0.5s nudge, then stop roborun connect --scan # DDS discovery — nothing to install on the robot ``` -If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine.** Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps. +If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine, and it works with both ROS 1 and ROS 2** — rosbridge speaks both, and RoboRun detects which (the DDS path is ROS 2-only). Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps. Optional extras: `pip install ros-agent[vision]` (YOLO + CLIP), `[sim]` (MuJoCo), `[ros]` (direct DDS), `[crypto]` (Ed25519 signing), `[anchor]` (RFC 3161 timestamping), `[fleet]` (R2 + DuckDB cross-robot), `[all]`. From 2865bfe121d02ca077fb627e5acc4afec47e9600 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:39:47 -0400 Subject: [PATCH 054/126] Lock Foxglove interop: messages match well-known schema shapes (runs open in Foxglove Studio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research: MCAP is Foxglove's open standard (default in ROS 2 + Isaac). Our channels already use foxglove.* schema names with matching fields (timestamp {sec,nsec}, frame_id, base64 data, pose{position,orientation}); test_foxglove_compat locks the shapes so a regression fails CI. Zero new code — verifies + protects existing interop. 240 passed. --- tests/test_foxglove_compat.py | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_foxglove_compat.py diff --git a/tests/test_foxglove_compat.py b/tests/test_foxglove_compat.py new file mode 100644 index 0000000..1c0ccfe --- /dev/null +++ b/tests/test_foxglove_compat.py @@ -0,0 +1,61 @@ +"""Foxglove interop: our MCAP messages match Foxglove's well-known schema shapes, +so RoboRun runs open natively in Foxglove Studio (MCAP is their open standard). + +Locks the field shapes in CI — a change that breaks Foxglove rendering fails here. +""" +from __future__ import annotations + +import base64 +import json +import time + +from roborun.recorder import RunRecorder + + +def _messages(tmp_path): + rec = RunRecorder(robot_id="t", root=tmp_path, checkpoint_interval=0.01) + t = time.time() + rec.write_camera(b"\xff\xd8jpeg", name="front", ts=t) + rec.write_pose(1.0, 2.0, 0.5, heading=0.3, ts=t) + rec.write_scan([1.0, 2.0, 3.0], 0.0, 0.0, 0.0, ts=t) + rec.close(do_anchor=False) + from mcap.reader import make_reader + out = {} + with open(rec.mcap_path, "rb") as fh: + for _s, channel, message in make_reader(fh).iter_messages(): + if channel.message_encoding == "json": + out.setdefault(channel.topic, json.loads(message.data)) + return out + + +def _is_ts(v): + return isinstance(v, dict) and "sec" in v and "nsec" in v + + +def test_compressed_image_matches_foxglove(tmp_path): + m = _messages(tmp_path)["/camera/front"] + assert _is_ts(m["timestamp"]) # foxglove Time {sec,nsec} + assert isinstance(m["frame_id"], str) + assert m["format"] == "jpeg" + base64.b64decode(m["data"]) # bytes field is base64 + + +def test_pose_in_frame_matches_foxglove(tmp_path): + m = _messages(tmp_path)["/pose"] + assert _is_ts(m["timestamp"]) and isinstance(m["frame_id"], str) + pos = m["pose"]["position"]; ori = m["pose"]["orientation"] + assert {"x", "y", "z"} <= set(pos) and {"x", "y", "z", "w"} <= set(ori) + + +def test_laser_scan_matches_foxglove(tmp_path): + m = _messages(tmp_path)["/scan"] + assert _is_ts(m["timestamp"]) and isinstance(m["frame_id"], str) + assert "start_angle" in m and "end_angle" in m and isinstance(m["ranges"], list) + + +def test_schemas_use_foxglove_wellknown_names(): + from roborun.recorder import SCHEMAS + for name in ("foxglove.CompressedImage", "foxglove.CompressedVideo", + "foxglove.PoseInFrame", "foxglove.LaserScan", + "foxglove.SceneUpdate", "foxglove.PointCloud"): + assert name in SCHEMAS From ee36932c5d899af9da67542e824ae5044811d537 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:42:27 -0400 Subject: [PATCH 055/126] VLA adapter: robot foundation models (GR00T/OpenVLA/RT-X) as native citizens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The founding 'Cursor for robotics' vision named VLA models native citizens. vla.py is a thin adapter: register_vla(name, fn) where fn maps (image_jpeg, instruction)->action; VLAPolicy.step grabs the frame, infers, and drives the same safety-clamped move(), recorded into the sealed run. Model is pluggable/optional — RoboRun ships the adapter, not the 3B weights. 245 passed. --- roborun/vla.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_vla.py | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 roborun/vla.py create mode 100644 tests/test_vla.py diff --git a/roborun/vla.py b/roborun/vla.py new file mode 100644 index 0000000..1a60b14 --- /dev/null +++ b/roborun/vla.py @@ -0,0 +1,67 @@ +"""VLA adapter — make robot foundation models (GR00T, OpenVLA, RT-X) native. + +A VLA maps (image, instruction) → action. This wires any such model into the +handle so it runs like any behavior, against the same safety-clamped `move()`, +recorded into the same sealed run. The *model* is pluggable and optional (you +bring GR00T/OpenVLA via HF, or a stub) — RoboRun bundles only the thin adapter. + + from roborun.vla import register_vla, VLAPolicy + register_vla("groot", lambda jpeg, instr: {...action...}) # your model + + @behavior(hz=5) + def vla_drive(robot): + VLAPolicy("groot").step(robot, "go to the charging dock") +""" +from __future__ import annotations + +from typing import Any, Callable + +# action keys the adapter maps onto robot.move(); a VLA may emit a subset +_ACTION_KEYS = ("forward", "strafe", "turn", "climb") + +_REGISTRY: dict[str, Callable[[bytes, str], dict]] = {} + + +def register_vla(name: str, model_fn: Callable[[bytes, str], dict]) -> None: + """Register a VLA: `model_fn(image_jpeg, instruction) -> {forward,strafe, + turn,climb}` (any subset, each in [-1,1]). The model is the caller's.""" + _REGISTRY[name] = model_fn + + +def list_vla() -> list[str]: + return sorted(_REGISTRY) + + +def load_vla(name: str) -> Callable[[bytes, str], dict]: + """Resolve a registered VLA, or try a known loader (GR00T/OpenVLA via HF if + installed). Raises with a clear, actionable message otherwise.""" + if name in _REGISTRY: + return _REGISTRY[name] + raise RuntimeError( + f"VLA {name!r} not registered. register_vla({name!r}, fn) with a model " + f"that maps (jpeg, instruction)->action — e.g. GR00T N1 or OpenVLA from " + f"HuggingFace. RoboRun ships the adapter, not the 3B weights.") + + +class VLAPolicy: + """Drives the robot from a VLA, one inference per tick. Actuation passes the + same clamps + estop as everything else; nothing bypasses safety.""" + + def __init__(self, model: str | Callable[[bytes, str], dict]) -> None: + self._fn = model if callable(model) else load_vla(model) + + def infer(self, image_jpeg: bytes, instruction: str) -> dict: + action = self._fn(image_jpeg, instruction) or {} + return {k: float(action.get(k, 0.0)) for k in _ACTION_KEYS} + + def step(self, robot: Any, instruction: str) -> dict: + """Grab the current frame, infer, and move. Returns the action taken + (or a no-op {} if there's no frame yet).""" + jpeg = robot.frame_jpeg() if hasattr(robot, "frame_jpeg") else None + if not jpeg: + robot.stop() + return {} + a = self.infer(jpeg, instruction) + robot.move(forward=a["forward"], strafe=a["strafe"], + turn=a["turn"], climb=a["climb"]) + return a diff --git a/tests/test_vla.py b/tests/test_vla.py new file mode 100644 index 0000000..e9ff6a9 --- /dev/null +++ b/tests/test_vla.py @@ -0,0 +1,51 @@ +"""VLA adapter: any (image, instruction)->action model drives the handle.""" +from __future__ import annotations + +import pytest + +from roborun.vla import register_vla, load_vla, list_vla, VLAPolicy + + +class FakeRobot: + def __init__(self, frame=b"\xff\xd8jpeg"): + self._frame = frame + self.moved = None + self.stopped = False + def frame_jpeg(self): + return self._frame + def move(self, forward=0, strafe=0, turn=0, climb=0): + self.moved = (forward, strafe, turn, climb) + def stop(self): + self.stopped = True + + +def test_register_and_load(): + register_vla("stub", lambda jpeg, instr: {"forward": 0.5, "turn": -0.2}) + assert "stub" in list_vla() + assert callable(load_vla("stub")) + + +def test_unknown_vla_raises_clear(): + with pytest.raises(RuntimeError) as e: + load_vla("groot_not_installed") + assert "register_vla" in str(e.value) + + +def test_policy_infers_and_moves(): + register_vla("forward_bot", lambda jpeg, instr: {"forward": 0.6}) + r = FakeRobot() + action = VLAPolicy("forward_bot").step(r, "drive forward") + assert action["forward"] == 0.6 and action["turn"] == 0.0 # subset → others 0 + assert r.moved == (0.6, 0.0, 0.0, 0.0) + + +def test_policy_no_frame_stops(): + register_vla("any", lambda jpeg, instr: {"forward": 1.0}) + r = FakeRobot(frame=None) + assert VLAPolicy("any").step(r, "go") == {} + assert r.stopped and r.moved is None + + +def test_policy_accepts_callable_directly(): + p = VLAPolicy(lambda jpeg, instr: {"turn": 0.3}) + assert p.infer(b"x", "spin")["turn"] == 0.3 From 03bace77a2eb20dddba922c3e4d6078988a7f72d Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 01:48:17 -0400 Subject: [PATCH 056/126] Front-end wording: plain-language copy for normal users (search/analytics/scenarios/timeline/run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote intros, mode labels ('by meaning'/'by object' not 'semantic'/'label'), button text ('Save as dataset'), and especially empty states to GUIDE the user (record a run, or 'roborun demo') — Foxglove-style benefit-oriented copy. No behavior change; clearer for someone who isn't a robotics engineer. --- roborun/web/analytics.html | 3 ++- roborun/web/run.html | 1 + roborun/web/scenarios.html | 8 ++++---- roborun/web/search.html | 12 ++++++------ roborun/web/timeline.html | 5 +++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index c17de29..b7c8874 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -42,6 +42,7 @@

📊 ANALYTICS

+

A live overview of everything your robots have seen and done — what they detected, how often, which robots are active, and how your behaviors score. Updates every few seconds.

Detections by label (all time)

@@ -59,7 +60,7 @@

📊 ANALYTICS

${i[labelKey]} ${pct?(i[valKey]+'%'):i[valKey]}
`).join("") - : '
no data yet — record some runs
'; + : '
Nothing here yet — record a run (cockpit → M) or run roborun demo to load sample data.
'; }; function spark(el,pts){ if(!pts.length){ el.innerHTML='
no data
'; return; } diff --git a/roborun/web/run.html b/roborun/web/run.html index 696830f..fc6ba09 100644 --- a/roborun/web/run.html +++ b/roborun/web/run.html @@ -28,6 +28,7 @@

RUN

+

Replay a single run. Drag the slider to scrub through time — the camera frame, position, speed, and obstacle distance all move together. Hit ⚑ Flag to bookmark a moment worth revisiting.

loading…
diff --git a/roborun/web/search.html b/roborun/web/search.html index 2eb72a5..415defa 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -57,9 +57,9 @@

🔍 SEARCH OVER TIME

- - - + + +
From a96668474d45c03bbf22c47eedbc39a99e168434 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 09:54:05 -0400 Subject: [PATCH 059/126] =?UTF-8?q?Clarify=20Fleet=20=E2=86=92=20'Fleet=20?= =?UTF-8?q?Lab'=20(it's=20a=20swarm=20sandbox;=20real=20robot=20status=20i?= =?UTF-8?q?s=20in=20Analytics)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user clicking 'fleet' expected a robot roster but got the swarm-coordination sandbox. Relabel the nav + page header, and point to Analytics for actual fleet status. Pure wording/expectation fix. --- roborun/web/analytics.html | 2 +- roborun/web/arena.html | 2 +- roborun/web/fleet.html | 4 ++-- roborun/web/scenarios.html | 2 +- roborun/web/search.html | 2 +- roborun/web/timeline.html | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index c1ff590..b2aac06 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -38,7 +38,7 @@

📊 ANALYTICS

diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 1e26858..d6e0581 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -514,7 +514,7 @@ 🔍 Search over time 🕓 Timeline 📊 Analytics - ⬡ Fleet + ⬡ Fleet Lab diff --git a/roborun/web/fleet.html b/roborun/web/fleet.html index b6a997f..ac3fe06 100644 --- a/roborun/web/fleet.html +++ b/roborun/web/fleet.html @@ -197,8 +197,8 @@
-
◈◈◈FLEET - — a swarm covering ground over an imperfect radio
+
◈◈◈FLEET LAB + — a sandbox for swarm coordination · for your real robots' status, see Analytics
diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html index a2d1f61..36dc6c9 100644 --- a/roborun/web/scenarios.html +++ b/roborun/web/scenarios.html @@ -55,7 +55,7 @@

SCENARIOS

scenarios timeline analytics - fleet + fleet lab search diff --git a/roborun/web/search.html b/roborun/web/search.html index 415defa..28aef07 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -47,7 +47,7 @@

🔍 SEARCH OVER TIME

diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index 8a516a1..49bb328 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -37,7 +37,7 @@

🕓 TIMELINE

From e94e626b24b31800ee681ab31bb4db60110ab4e3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 09:55:53 -0400 Subject: [PATCH 060/126] Timeline: consistent guiding empty state for the sightings strip --- roborun/web/timeline.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index 49bb328..cbc5e99 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -58,7 +58,7 @@

Event stream (live)

$("#strip").innerHTML = e.length? e.map(m=>`
${time(m.ts)}
`).join("") - : '
no keyframes yet
'; + : '
No camera snapshots yet — start a camera or sim, or run roborun demo.
'; }catch(e){} } From bdca2431243f325923732501bbf33ab9603ea3d3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 09:56:34 -0400 Subject: [PATCH 061/126] README headline: surface the sealed + searchable data layer ('find anything your robots ever saw, across all of time') The search/data-over-time capability is now central (research: it's Foxglove's $40M direction + the data layer the foundation-model companies need). Reflect it in the one-line pitch. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d4b121..7824dac 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

RoboRun: Write a Robot Behavior Once, Run It on Any ROS 1/2 Robot

-

The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents, every run flight-recorded.

+

The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents — and every run is flight-recorded, sealed, and searchable, so you can find anything your robots ever saw, across all of time.

PyPI From cd05eafabc55c1c3045b28fcbe0a60b772de682c Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 09:57:27 -0400 Subject: [PATCH 062/126] README: replace internal 'Antioch loop' reference with plain user-facing wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Competitor names belong in private research, not user docs. (The Foxglove mention stays — it's a real interop claim: our MCAP runs open in Foxglove Studio.) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7824dac..0867f76 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Every run — sim, real robot, or webcam — flows through one loop: **YOLO + CL The cockpit's **▤ VIEWS** menu opens the dashboards (also `roborun demo` to populate them instantly): - **/search** — find anything/anyone over time; export the hits as a labeled dataset (sealed provenance). -- **/scenarios** — scored runs + suites with pass-rates; run a scenario or a whole suite (the Antioch loop). +- **/scenarios** — give a behavior a task, run it, and see if it passed; group runs into suites with a pass-rate, and re-run after every change. - **/run** — per-run trajectory · velocity · clearance · LiDAR, with **synced playback** (scrub a moment → the frame the robot saw) and **⚑ Flag** to bookmark incidents to revisit. - **/analytics** — detections over time, suite pass-rates, per-robot fleet activity. - **/timeline** — the live event stream + recent sightings. From 2241633a12747e1231a56e596dc0c8dc8350ade0 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:00:10 -0400 Subject: [PATCH 063/126] Dataset provenance manifest: verifiable lineage for curated training data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research (C2PA Content Credentials v2.3 → MLOps; EU AI Act making provenance a compliance requirement): export_dataset now writes provenance.json mapping each image to its sealed, RFC-3161-timestamped source run (Merkle root + anchor status + verifiable flag). The robot-data analogue of Content Credentials — RoboRun's core differentiator, made concrete and timely. 247 passed. --- roborun/session.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_dataset_export.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/roborun/session.py b/roborun/session.py index 4b65a3c..c079742 100644 --- a/roborun/session.py +++ b/roborun/session.py @@ -188,7 +188,41 @@ def export_dataset(store: Any, query: Any, out_dir: str, by: str = "label", (out / "dataset.json").write_text(json.dumps( {"query": str(query), "by": by, "count": len(manifest), "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, indent=2)) - return {"ok": True, "count": len(manifest), "dir": str(out)} + + # Provenance manifest — make the training set's integrity independently + # verifiable (the robot-data analogue of C2PA Content Credentials; matters as + # the EU AI Act + ISO push provenance toward a compliance requirement). Each + # image traces to a sealed, RFC-3161-timestamped run; the seal is the proof. + runs = {} + for m in manifest: + rid = m.get("run_id") + if not rid or rid in runs: + continue + mcap = _find_mcap(rid, m.get("robot_id")) + seal = mcap.with_suffix(".seal") if mcap is not None else None + if seal is not None and seal.exists(): + try: + s = json.loads(seal.read_text()) + runs[rid] = {"merkle_root": s.get("merkle_root"), + "sealed_at": s.get("sealed_at"), + "anchor": (s.get("anchor") or {}).get("status"), + "robot_id": s.get("robot_id")} + except Exception: + runs[rid] = {"merkle_root": None, "note": "unreadable seal"} + else: + runs[rid] = {"merkle_root": None, "note": "no seal (unsealed run)"} + sealed = sum(1 for r in runs.values() if r.get("merkle_root")) + (out / "provenance.json").write_text(json.dumps({ + "schema": "roborun-dataset-provenance/1", + "images": len(manifest), "source_runs": len(runs), + "sealed_runs": sealed, + "verifiable": sealed == len(runs) and len(runs) > 0, + "note": "each image traces to a source run; sealed runs carry a Merkle root " + "+ RFC 3161 timestamp — verify with `python -m roborun.recorder verify`.", + "runs": runs, + }, indent=2)) + return {"ok": True, "count": len(manifest), "dir": str(out), + "sealed_runs": sealed, "source_runs": len(runs)} def search(store: Any, query: Any, by: str = "clip", k: int = 10, diff --git a/tests/test_dataset_export.py b/tests/test_dataset_export.py index 9324335..54f515d 100644 --- a/tests/test_dataset_export.py +++ b/tests/test_dataset_export.py @@ -42,3 +42,35 @@ def test_export_empty_query(tmp_path): store = SpatialMemoryStore(db_path=tmp_path / "m.db") r = export_dataset(store, "nothing", str(tmp_path / "ds"), by="label") assert r["ok"] and r["count"] == 0 + + +def test_export_writes_provenance_manifest(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + import json, time + import numpy as np + from roborun.recorder import RunRecorder + from roborun.events import runs_root + from roborun.observations import StreamingExtractor + from roborun.spatial_memory import SpatialMemoryStore + from roborun.session import export_dataset + + store = SpatialMemoryStore() + rec = RunRecorder(robot_id="r1", root=runs_root(), checkpoint_interval=0.05) + rec.extractor = StreamingExtractor(store, robot_id="r1", run_id=rec.run_id, source="production") + t0 = time.time() + for i in range(4): + rec.write_pose(i * 0.1, 0, 0, ts=t0 + i) + rec.write_detections([{"label": "pallet", "score": 1, "bbox": [0, 0, 1, 1]}], name="cam", ts=t0 + i) + rec.write_camera(b"\xff\xd8jpeg" + bytes([i]), name="cam", ts=t0 + i) + seal = rec.close(do_anchor=False) + + out = tmp_path / "ds" + r = export_dataset(store, "pallet", str(out), by="label") + assert r["count"] >= 1 and r["source_runs"] == 1 + prov = json.loads((out / "provenance.json").read_text()) + assert prov["schema"] == "roborun-dataset-provenance/1" + assert prov["source_runs"] == 1 and prov["sealed_runs"] == 1 + assert prov["verifiable"] is True + # each image's run traces to the sealed run's Merkle root + run_info = list(prov["runs"].values())[0] + assert run_info["merkle_root"] == seal["merkle_root"] From 7190a4e225f86ef3493cd2ee5580026cb6bbee0a Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:03:00 -0400 Subject: [PATCH 064/126] Search first-run: a 3-step quick-start greets new users when the index is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX research (quick-win-before-setup, checklists, educate-via-empty-state): when there's no data, /search shows a friendly 'run roborun demo → or record → then search' guide instead of a blank page. Progressive disclosure — only shown when empty. Front-end usability for someone's first five minutes. --- roborun/web/search.html | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/roborun/web/search.html b/roborun/web/search.html index 28aef07..045745d 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -106,8 +106,24 @@

🔍 SEARCH OVER TIME

${when}${score}
${r.source||"?"} · ${r.source_id||r.robot_id||""}
`; - }).join("") : `
Nothing found yet.

If this is a fresh install, there's no data to search — record a run (open the cockpit and press M) or run roborun demo in your terminal to load sample data. Already have runs? Try the label tab or widen the time range.
`; + }).join("") : `
Nothing found yet.

If this is a fresh install, there's no data to search — record a run (open the cockpit and press M) or run roborun demo in your terminal to load sample data. Already have runs? Try the by object tab or widen the time range.
`; } + +// First-run: greet a brand-new user with a 3-step quick start (only when empty). +async function firstRun(){ + try{ + const d=await (await fetch("/api/analytics")).json(); + if((d.observations?.total||0) > 0) return; // there's data → no onboarding + $("#grid").innerHTML = `
+
👋 Welcome — let's get something to search.
+
1. In your terminal, run roborun demo — it records a few sample runs and indexes them. (Quickest win.)
+
2. Or open the cockpit, start a sim or camera, and press M to record.
+
3. Then come back and search — by meaning ("person in red") or by object ("forklift"), across everything, over all of time.
+
Once data exists, this page becomes your robots' memory: find anyone or anything they've ever seen, and export the results as a labeled dataset with verifiable provenance.
+
`; + }catch(e){} +} +firstRun(); From 60b459ce02c1374c3d2347c4f2b7868c0d2b41cd Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:05:31 -0400 Subject: [PATCH 065/126] Startup guidance: 'roborun' greets new users with the quick-win + where the views are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX research (developer tools need a clear 'where is everything' + a fast first win). On boot with an empty index, print 'run roborun demo → open /search' and list the Views. Terminal is the local-runner's front door; make it self-explanatory. 246 passed. --- roborun/server.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/roborun/server.py b/roborun/server.py index 001380d..e2cd083 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -402,8 +402,16 @@ def _autostart() -> None: server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"\n RoboRun is live: http://{HOST}:{PORT}") - print(f" Telemetry WS: ws://127.0.0.1:8766") - print(f" MCP endpoint: http://{HOST}:{PORT}/mcp\n") + print(f" MCP endpoint: http://{HOST}:{PORT}/mcp · Telemetry WS: ws://127.0.0.1:8766") + # Quick-start: point new users at the dashboards + a one-command win. + try: + from roborun.spatial_memory import SpatialMemoryStore + if SpatialMemoryStore().stats().get("total", 0) == 0: + print(f"\n New here? Run roborun demo in another terminal to load sample data,") + print(f" then open http://{HOST}:{PORT}/search to find anything your robots have seen.") + except Exception: + pass + print(f"\n Views: /search · /scenarios · /timeline · /analytics · /run (or the cockpit's ▤ VIEWS menu)\n") from roborun.events import emit emit("system", "server", "roborun started", {"port": PORT}) try: From 992f837c316f181b08f1605928845f16781da685 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:08:52 -0400 Subject: [PATCH 066/126] Storage/retention visibility for fleet-cost awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research: robots make 30-100 GB/shift (100 robots = 3-10 TB/shift); cloud is costly, so local-first + retention is the strategy. retention.status() reports footprint (used/cap/%, runs, sealed, uploaded, evictable); analytics shows it as a KPI with the cost context. Composes existing GC functions — no new subsystem. 247 passed. --- roborun/retention.py | 21 +++++++++++++++++++++ roborun/routes/search.py | 5 +++++ roborun/web/analytics.html | 10 +++++++--- tests/test_retention.py | 11 +++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/roborun/retention.py b/roborun/retention.py index fd4cfd1..2d75a96 100644 --- a/roborun/retention.py +++ b/roborun/retention.py @@ -37,6 +37,27 @@ def dir_bytes(root: Path) -> int: return sum(p.stat().st_size for p in root.rglob("*") if p.is_file()) +def status(root: Path | None = None, max_gb: float | None = None) -> dict[str, Any]: + """Storage footprint + retention picture for fleet-cost awareness (robots make + 30–100 GB/shift; local-first + GC keeps cost sane). Read-only; evicts nothing.""" + root = root or runs_root() + cap_gb = DEFAULT_MAX_GB if max_gb is None else max_gb + if not root.exists(): + return {"used_bytes": 0, "used_gb": 0.0, "cap_gb": cap_gb, "pct": 0.0, + "runs": 0, "sealed": 0, "uploaded": 0, "evictable_bytes": 0} + bodies = _mcap_bodies(root) + used = dir_bytes(root) + sealed = sum(1 for m in bodies if _is_sealed(m)) + uploaded = sum(1 for m in bodies if _is_uploaded(m)) + evictable = sum(m.stat().st_size for m in bodies + if _is_sealed(m) and _is_uploaded(m)) + return {"used_bytes": used, "used_gb": round(used / (1 << 30), 3), + "cap_gb": cap_gb, "pct": round(used / (cap_gb * (1 << 30)) * 100, 1) if cap_gb else 0.0, + "runs": len(bodies), "sealed": sealed, "uploaded": uploaded, + "evictable_bytes": evictable, + "evictable_gb": round(evictable / (1 << 30), 3)} + + def enforce(root: Path | None = None, max_gb: float | None = None, require_uploaded: bool = True) -> dict[str, Any]: """Evict oldest evictable MCAP bodies until total ≤ max_gb. Returns a report. diff --git a/roborun/routes/search.py b/roborun/routes/search.py index 7460a64..d4d72bf 100644 --- a/roborun/routes/search.py +++ b/roborun/routes/search.py @@ -120,6 +120,11 @@ def analytics(h): "anchored": sum(1 for r in runs if r.get("anchored"))} except Exception: out["runs"] = {"count": 0} + try: + from roborun.retention import status as storage_status + out["storage"] = storage_status() + except Exception: + out["storage"] = {} try: from roborun.routes.fleet import _load_fleet fleet = _load_fleet() diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index b2aac06..2cae72f 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -71,13 +71,17 @@

📊 ANALYTICS

} async function load(){ let d; try{ d=await (await fetch("/api/analytics")).json(); }catch(e){ return; } - const obs=d.observations||{}, runs=d.runs||{}, fleet=d.fleet||{}; - const kpi=(n,l)=>`
${n}
${l}
`; + const obs=d.observations||{}, runs=d.runs||{}, fleet=d.fleet||{}, st=d.storage||{}; + const kpi=(n,l,t)=>`
${n}
${l}
`; + const stored = st.used_gb!=null + ? kpi(`${st.used_gb}G`, `of ${st.cap_gb}G used (${st.pct||0}%)`, + `Local storage. ${st.evictable_gb||0}G is sealed + backed up and can be auto-freed (${st.runs||0} runs, ${st.sealed||0} sealed). Robots make 30–100 GB/shift — RoboRun stays local-first and keeps the tiny proofs even after freeing space.`) + : kpi((((runs.total_bytes||0)/1e9).toFixed(2))+"G","data recorded"); $("#kpis").innerHTML=[ kpi(obs.total??0,"things seen"), kpi(obs.with_embeddings??0,"AI-searchable"), kpi(runs.count??0,"saved runs"), - kpi((((runs.total_bytes||0)/1e9).toFixed(2))+"G","data recorded"), + stored, kpi(fleet.total??0,"robots"), kpi((d.suites||[]).length,"test suites"), ].join(""); diff --git a/tests/test_retention.py b/tests/test_retention.py index 82e12b8..9318c44 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -40,3 +40,14 @@ def test_never_evicts_unsealed_or_unuploaded(tmp_path): out = retention.enforce(tmp_path, max_gb=1 / 1024) # 1 MB cap, way over assert out["evicted"] == [] assert out["kept_over_cap"] is True # honestly reports it couldn't get under + + +def test_status_reports_footprint(tmp_path): + from roborun import retention + _run(tmp_path, "r", "a", 2, sealed=True, uploaded=True) + _run(tmp_path, "r", "b", 3, sealed=True, uploaded=False) + st = retention.status(tmp_path, max_gb=1.0) + assert st["runs"] == 2 and st["sealed"] == 2 and st["uploaded"] == 1 + assert st["used_gb"] > 0 and st["cap_gb"] == 1.0 + # only the sealed+uploaded run is evictable + assert st["evictable_bytes"] == 2 * 1024 * 1024 From 55c59739a9a10f2f73425ab560c0b41352923211 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:09:59 -0400 Subject: [PATCH 067/126] =?UTF-8?q?Add=20'roborun=20help'=20=E2=80=94=20on?= =?UTF-8?q?e=20screen=20listing=20all=20verbs=20(local-runner=20discoverab?= =?UTF-8?q?ility)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research: dev tools need a clear 'where is everything?'. roborun help|--help|-h prints the full verb list (demo/connect/search/scenarios/dataset/flag/skill) + the UI views. --- roborun/server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/roborun/server.py b/roborun/server.py index e2cd083..cb2de0a 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -273,8 +273,25 @@ def _frame_recorder_loop() -> None: time.sleep(0.1) +_HELP = """RoboRun — run robots, record everything, search it over time. + + roborun start the server + UI (http://localhost:8765) + roborun demo load sample data so the dashboards aren't empty + roborun connect connect a real robot (ROS 1 or ROS 2, via rosbridge) + roborun search find anything/anyone across every recorded run + roborun scenarios list / run scored behavior tests (run | suite ) + roborun dataset curate a labeled training set from a search (sealed provenance) + roborun flag bookmark a moment in a run to revisit + roborun skill <...> install / manage skills from GitHub + +Open the cockpit, then its ▤ VIEWS menu for: search · scenarios · timeline · analytics · fleet.""" + + def main() -> None: import sys + if len(sys.argv) > 1 and sys.argv[1] in ("help", "--help", "-h"): + print(_HELP) + raise SystemExit(0) if len(sys.argv) > 1 and sys.argv[1] == "skill": from roborun.skills.manager import cli raise SystemExit(cli(sys.argv[2:])) From cfd0882880c7e2604d549e95a2e6bf9c1e6ee55f Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:11:06 -0400 Subject: [PATCH 068/126] Fleet panel: robot status at a glance (active/idle/offline dot from last-seen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research: fleet operators want 'robot state at a glance', not raw telemetry. The fleet panel now shows a colored status dot + active/idle/offline per robot, derived from last-seen recency. Composes existing data — no new endpoint. --- roborun/web/analytics.html | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index 2cae72f..4a3f500 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -89,13 +89,18 @@

📊 ANALYTICS

spark($("#sparkwrap"), d.over_time||[]); bars($("#suites"), (d.suites||[]).map(s=>({suite:s.suite,pct:Math.round(s.pass_rate*100)})), "suite","pct",true); bars($("#sources"), d.sources||[], "source","count",false); - const robots=d.robots||[]; + const robots=d.robots||[]; const now=Date.now()/1000; $("#fleet").innerHTML = robots.length? `
`+ - robots.map(r=>`
-
${r.robot_id}
-
${r.observations} obs · ${r.top_label||"—"}
-
last seen ${r.last_seen?new Date(r.last_seen*1000).toLocaleString():"—"}
-
`).join("")+`
` + robots.map(r=>{ + const age = r.last_seen? now-r.last_seen : Infinity; + const active = age < 120; // seen in the last 2 min → active + const dot = active? "#00d47e" : (age<3600? "#d4a030" : "#6b7b88"); + const state = active? "active" : (age<3600? "idle" : "offline"); + return `
+
${r.robot_id} ${state}
+
${r.observations} seen · mostly ${r.top_label||"—"}
+
last active ${r.last_seen?new Date(r.last_seen*1000).toLocaleString():"—"}
+
`;}).join("")+`` : '
No robots have recorded data yet — connect one with roborun connect, start a sim, or run roborun demo.
'; } load(); setInterval(load, 5000); From e358c94bc71d6e8beb198bee8c89c172fffccfe2 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:16:27 -0400 Subject: [PATCH 069/126] =?UTF-8?q?Cockpit=20feed=20state:=20'LIVE'=20?= =?UTF-8?q?=E2=86=92=20'NO=20SIGNAL'=20when=20the=20camera=20feed=20freeze?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teleop UX research: operators must know if video is live or frozen (a stale feed >300ms is unsafe to drive against). The cockpit showed a static 'LIVE'; now the indicator turns amber + 'NO SIGNAL' on frame errors, green + 'LIVE' on success. Isolated change to the camera pump loop; syntax-checked, cockpit serves clean. --- roborun/web/arena.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index 405828d..c670dde 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -1493,10 +1493,18 @@ function enterCockpit(src) { // stream = the robot camera, polled one frame at a time (deterministic) let camSource = "robot"; $("ck-src").addEventListener("change", (e) => { camSource = e.target.value; }); + // Honest feed state: operators must know if the video is live or frozen + // (teleop UX — a stale feed at >300ms is unsafe to drive against). + const setLive = (ok) => { + const el = $("ck-live"); if (!el) return; + const dot = el.querySelector(".dot"), txt = el.querySelector("span:last-child"); + if (dot) dot.style.background = ok ? "#00d47e" : "#d4a030"; + if (txt) txt.textContent = ok ? "LIVE" : "NO SIGNAL"; + }; (function pumpCam() { const img = new Image(); - img.onload = () => { $("ck-cam").src = img.src; setTimeout(pumpCam, 90); }; - img.onerror = () => setTimeout(pumpCam, 400); + img.onload = () => { $("ck-cam").src = img.src; setLive(true); setTimeout(pumpCam, 90); }; + img.onerror = () => { setLive(false); setTimeout(pumpCam, 400); }; img.src = `${RT_BASE()}/api/camera/frame?source=${camSource}&t=${Date.now()}`; })(); fetch("/api/sources").then((r) => r.json()).then((s) => { From b572ec39f725105779622588352ec664cf835633 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:18:22 -0400 Subject: [PATCH 070/126] CLI: 'roborun ask' (NL command to the agent) + 'roborun status' (dimOS-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dimOS has 'dimos agent-send' + 'dimos status'. roborun ask "patrol the lobby" streams the running agent's reply (vibe robotics from the terminal); roborun status reports server/data/robot at a glance. Lean — ask POSTs to the existing /api/agent/chat; status composes existing fns. Clean errors when the server is down. --- roborun/cli.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++ roborun/server.py | 8 ++++++ tests/test_cli.py | 19 +++++++++++++ 3 files changed, 95 insertions(+) diff --git a/roborun/cli.py b/roborun/cli.py index 736d33b..446e057 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -34,6 +34,74 @@ def search_cli(argv: list[str]) -> int: return 0 +def _base_url() -> str: + import os + return f"http://127.0.0.1:{os.environ.get('ROBORUN_PORT', '8765')}" + + +def ask_cli(argv: list[str]) -> int: + """Send a natural-language instruction to the running robot's agent (it sees + the live camera and can move/act). `roborun ask "patrol the lobby"`. + Needs `roborun` running in another terminal.""" + import json + import urllib.request + msg = " ".join(argv).strip() + if not msg: + print('usage: roborun ask "what you want the robot to do"'); return 2 + req = urllib.request.Request(_base_url() + "/api/agent/chat", + data=json.dumps({"message": msg}).encode(), + headers={"Content-Type": "application/json"}) + try: + resp = urllib.request.urlopen(req, timeout=120) + except Exception as exc: + print(f"can't reach RoboRun ({exc}). Start it with `roborun` first."); return 1 + for raw in resp: + line = raw.decode().strip() + if not line.startswith("data:"): + continue + try: + ev = json.loads(line[5:].strip()) + except Exception: + continue + if ev.get("type") == "text": + print(ev.get("text", ""), end="", flush=True) + elif ev.get("type") == "tool_use": + print(f"\n · {ev.get('tool_name')}({json.dumps(ev.get('tool_input', {}))[:60]})", flush=True) + elif ev.get("type") == "error": + print(f"\n[agent error] {ev.get('error')}"); return 1 + elif ev.get("type") == "done": + print(); return 0 + print(); return 0 + + +def status_cli(argv: list[str]) -> int: + """Quick health: is the server up, what's connected, how much is recorded.""" + import json + import urllib.request + up = False + try: + with urllib.request.urlopen(_base_url() + "/api/agent/status", timeout=3) as r: + json.loads(r.read()); up = True + except Exception: + pass + print(f"server: {'up at ' + _base_url() if up else 'not running (start with `roborun`)'}") + try: + from roborun.spatial_memory import SpatialMemoryStore + from roborun.recorder import list_runs + from roborun.retention import status as st + s = SpatialMemoryStore().stats() + runs = list_runs() + ss = st() + print(f"data: {s.get('total', 0)} things seen across {len(runs)} runs " + f"({ss.get('used_gb', 0)}/{ss.get('cap_gb', 0)} GB)") + from roborun.connect import saved_robot + rb = saved_robot() + print(f"robot: {rb['host'] + ' (' + rb.get('type', '?') + ')' if rb else 'none connected — `roborun connect `'}") + except Exception as exc: + print(f"data: (unavailable: {exc})") + return 0 + + def demo_cli(argv: list[str]) -> int: """Seed a populated demo so a fresh install shows a live UI immediately: a recorded synthetic run (camera+detections+pose, indexed + searchable) and a diff --git a/roborun/server.py b/roborun/server.py index cb2de0a..c3d8a36 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -276,7 +276,9 @@ def _frame_recorder_loop() -> None: _HELP = """RoboRun — run robots, record everything, search it over time. roborun start the server + UI (http://localhost:8765) + roborun status is it running, what's connected, how much is recorded roborun demo load sample data so the dashboards aren't empty + roborun ask "" tell the robot what to do in plain English (agent drives) roborun connect connect a real robot (ROS 1 or ROS 2, via rosbridge) roborun search find anything/anyone across every recorded run roborun scenarios list / run scored behavior tests (run | suite ) @@ -310,6 +312,12 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "demo": from roborun.cli import demo_cli raise SystemExit(demo_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "ask": + from roborun.cli import ask_cli + raise SystemExit(ask_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "status": + from roborun.cli import status_cli + raise SystemExit(status_cli(sys.argv[2:])) if len(sys.argv) > 1 and sys.argv[1] == "flag": from roborun.incidents import flag, list_incidents if len(sys.argv) > 2: diff --git a/tests/test_cli.py b/tests/test_cli.py index 51e970e..4d1bd0f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,3 +46,22 @@ def test_demo_cli_populates(capsys): from roborun.scenario import list_suites assert SpatialMemoryStore().stats()["total"] > 0 assert any(s["suite"] == "demo" for s in list_suites()) + + +def test_status_cli_offline(capsys): + from roborun.cli import status_cli + assert status_cli([]) == 0 + out = capsys.readouterr().out + assert "server:" in out and "data:" in out + + +def test_ask_cli_clean_error_when_server_down(capsys, monkeypatch): + monkeypatch.setenv("ROBORUN_PORT", "59999") # nothing listening + from roborun.cli import ask_cli + assert ask_cli(["go", "forward"]) == 1 + assert "can't reach RoboRun" in capsys.readouterr().out + + +def test_ask_cli_needs_a_message(capsys): + from roborun.cli import ask_cli + assert ask_cli([]) == 2 From 2b9b37e511e72d961dcf847ef9a62b7a7fa89036 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:21:06 -0400 Subject: [PATCH 071/126] Harden 'roborun ask': surface the agent error (no API key) instead of printing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-unavailable path returns a plain JSON body, not SSE — buffer non-stream lines and print the error if nothing streamed, so the user sees 'needs ANTHROPIC_API_KEY' rather than a blank line. --- roborun/cli.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/roborun/cli.py b/roborun/cli.py index 446e057..0a30551 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -55,23 +55,34 @@ def ask_cli(argv: list[str]) -> int: resp = urllib.request.urlopen(req, timeout=120) except Exception as exc: print(f"can't reach RoboRun ({exc}). Start it with `roborun` first."); return 1 + printed = False + buf = "" for raw in resp: line = raw.decode().strip() if not line.startswith("data:"): + buf += line # non-SSE body (e.g. a plain JSON error) continue try: ev = json.loads(line[5:].strip()) except Exception: continue if ev.get("type") == "text": - print(ev.get("text", ""), end="", flush=True) + print(ev.get("text", ""), end="", flush=True); printed = True elif ev.get("type") == "tool_use": print(f"\n · {ev.get('tool_name')}({json.dumps(ev.get('tool_input', {}))[:60]})", flush=True) elif ev.get("type") == "error": print(f"\n[agent error] {ev.get('error')}"); return 1 elif ev.get("type") == "done": print(); return 0 - print(); return 0 + if printed: + print(); return 0 + # nothing streamed — surface a JSON error body (e.g. agent unavailable) + try: + err = json.loads(buf) + print(err.get("error", "the agent isn't available.")) + except Exception: + print("the agent isn't available — set ANTHROPIC_API_KEY and restart `roborun`.") + return 1 def status_cli(argv: list[str]) -> int: From db6a42e5b758e67cb722e6d267198a3a99e57b30 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:22:58 -0400 Subject: [PATCH 072/126] Mobile: scenarios header wraps + table scrolls horizontally on narrow screens Operators check fleets/runs on phones. The scenarios header lacked flex-wrap and the 7-column table squished; now the header wraps and the table scrolls. (Other dashboards already had flex-wrap + auto-fill grids.) --- roborun/web/scenarios.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html index 36dc6c9..14e6c1a 100644 --- a/roborun/web/scenarios.html +++ b/roborun/web/scenarios.html @@ -12,7 +12,7 @@ body{ margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } header{ display:flex; align-items:baseline; gap:16px; padding:18px 24px; - border-bottom:1px solid var(--line); } + border-bottom:1px solid var(--line); flex-wrap:wrap; } header h1{ font-size:16px; margin:0; letter-spacing:.5px; } header .sub{ color:var(--dim); font-size:12px; } header a{ color:var(--dim); text-decoration:none; margin-left:auto; } @@ -44,6 +44,7 @@ .filters button{ background:var(--panel); color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:4px 12px; cursor:pointer; font:inherit; font-size:12px; } .filters button.on{ color:var(--accent); border-color:var(--accent); } + @media(max-width:700px){ table{ display:block; overflow-x:auto; white-space:nowrap; } main{ padding:16px; } } From bde6a6c683ce97a15d393db12aea4b1a470b0d97 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:23:38 -0400 Subject: [PATCH 073/126] README quick-start: include the new CLI verbs (demo / ask / status / help) So users discover the full local-runner toolkit from the docs. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0867f76..ff575d5 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,16 @@ The cockpit's **▤ VIEWS** menu opens the dashboards (also `roborun demo` to po From the local runner, no browser needed: ```bash +roborun demo # load sample data so the dashboards aren't empty +roborun ask "patrol the lobby" # tell the robot what to do in plain English roborun search "person" # across every recorded run, all-time roborun scenarios run mjx_reach # score a scenario (vectorized MuJoCo, sealed) roborun dataset "forklift" ./ds # curate a labeled training set from a search +roborun status # is it running, what's connected, how much recorded ``` +(`roborun help` lists every verb.) + The robot handle gets it too: `robot.go_to_place("the charging dock")` navigates to where it last saw something (semantic memory), and the same `recall_place` is an MCP tool any agent can call. ## Connect a real robot From ef7962022580d55b1b6d5aff0685c1710a6845da Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:27:15 -0400 Subject: [PATCH 074/126] Emergency stop: POST /api/estop + 'roborun stop' (safety primitive) Safety research (LLM robots need execution-layer stop + auditability): estop halts every actuator AND disables running behaviors so nothing re-commands motion, and records the stop into the sealed run as evidence. Reachable via REST and the CLI (falls back to in-process if the server is down). Composes the existing estop MCP tool. 252 passed. --- roborun/cli.py | 22 ++++++++++++++++++++ roborun/routes/ros.py | 19 +++++++++++++++++ roborun/server.py | 4 ++++ tests/test_estop.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 tests/test_estop.py diff --git a/roborun/cli.py b/roborun/cli.py index 0a30551..98d299c 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -85,6 +85,28 @@ def ask_cli(argv: list[str]) -> int: return 1 +def stop_cli(argv: list[str]) -> int: + """Emergency stop: halt every actuator + disable behaviors. `roborun stop`.""" + import json + import urllib.request + req = urllib.request.Request(_base_url() + "/api/estop", data=b"{}", + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + json.loads(r.read()) + print("🛑 emergency stop sent — actuators halted, behaviors disabled.") + return 0 + except Exception: + # server down: still try an in-process estop so the verb always does something + try: + from roborun.ros_mcp import handle_tool_call + handle_tool_call("estop", {}) + print("🛑 estop sent in-process (server wasn't running).") + return 0 + except Exception as exc: + print(f"couldn't send estop: {exc}"); return 1 + + def status_cli(argv: list[str]) -> int: """Quick health: is the server up, what's connected, how much is recorded.""" import json diff --git a/roborun/routes/ros.py b/roborun/routes/ros.py index da32b45..9e871ca 100644 --- a/roborun/routes/ros.py +++ b/roborun/routes/ros.py @@ -21,6 +21,25 @@ def _get_ros_client(host: str | None = None): return client +@post("/api/estop") +def estop(h, payload): + """Emergency stop — halt every live actuator now. The one command that's always + allowed; also disables all running behaviors so nothing re-commands motion. + Recorded into the sealed run as evidence the stop happened.""" + from roborun.ros_mcp import handle_tool_call + res = handle_tool_call("estop", {}) + try: + from roborun.behaviors import BehaviorRunner + for s in BehaviorRunner.get().statuses(): + if s.get("enabled"): + BehaviorRunner.get().set_enabled(s["name"], False) + except Exception: + pass + from roborun.events import emit + emit("system", "estop", "EMERGENCY STOP — actuators halted, behaviors disabled", {}) + send_json(h, 200, {"ok": True, "estop": res}) + + @get("/api/ros/cloud") def cloud(h): """World-frame lidar point cloud + live pose, for the deck's SPATIAL diff --git a/roborun/server.py b/roborun/server.py index c3d8a36..7055977 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -279,6 +279,7 @@ def _frame_recorder_loop() -> None: roborun status is it running, what's connected, how much is recorded roborun demo load sample data so the dashboards aren't empty roborun ask "" tell the robot what to do in plain English (agent drives) + roborun stop EMERGENCY STOP — halt all actuators + disable behaviors roborun connect connect a real robot (ROS 1 or ROS 2, via rosbridge) roborun search find anything/anyone across every recorded run roborun scenarios list / run scored behavior tests (run | suite ) @@ -318,6 +319,9 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "status": from roborun.cli import status_cli raise SystemExit(status_cli(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "stop": + from roborun.cli import stop_cli + raise SystemExit(stop_cli(sys.argv[2:])) if len(sys.argv) > 1 and sys.argv[1] == "flag": from roborun.incidents import flag, list_incidents if len(sys.argv) > 2: diff --git a/tests/test_estop.py b/tests/test_estop.py new file mode 100644 index 0000000..7826d11 --- /dev/null +++ b/tests/test_estop.py @@ -0,0 +1,48 @@ +"""Emergency stop — the always-allowed safety primitive, REST + CLI, recorded.""" +from __future__ import annotations + +import json, threading, urllib.request +from contextlib import closing +import pytest + + +@pytest.fixture() +def server(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + from roborun import server as srv + from http.server import ThreadingHTTPServer + httpd = ThreadingHTTPServer(("127.0.0.1", 0), srv.Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{httpd.server_address[1]}" + httpd.shutdown() + + +def test_estop_route_halts_and_records(server, monkeypatch): + monkeypatch.setenv("ROBORUN_PORT", server.rsplit(":", 1)[1]) + req = urllib.request.Request(server + "/api/estop", data=b"{}", + headers={"Content-Type": "application/json"}) + with closing(urllib.request.urlopen(req, timeout=8)) as r: + d = json.loads(r.read()) + assert d["ok"] + # the stop is recorded as evidence + from roborun.events import recent + assert any("STOP" in e.get("title", "").upper() for e in recent(50)) + + +def test_estop_disables_running_behaviors(server, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner_dir = tmp_path / "behaviors"; runner_dir.mkdir() + (runner_dir / "spin.py").write_text( + "from roborun.behaviors import behavior\n@behavior(hz=5)\ndef spin(robot):\n robot.stop()\n") + from roborun.behaviors import BehaviorRunner + r = BehaviorRunner.get(); r.dirs = [runner_dir]; r._mtimes.clear() + for loops in list(r._loops.values()): + for lp in loops: lp.halt() + r._loops.clear(); r.start() + import time; time.sleep(1.2) + assert any(s["enabled"] for s in r.statuses()) # spin is running + req = urllib.request.Request(server + "/api/estop", data=b"{}", + headers={"Content-Type": "application/json"}) + with closing(urllib.request.urlopen(req, timeout=8)): pass + time.sleep(0.3) + assert all(not s["enabled"] for s in r.statuses()) # all disabled after estop From 4d7296702a46941c6897e09a7a4739de2cd727d9 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:28:38 -0400 Subject: [PATCH 075/126] README handle table: add robot.go_to_place (semantic navigation) so developers find it --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ff575d5..c52686e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Want real eyes instead of the sim? `pip install 'ros-agent[vision]'` (YOLO + CLI | `robot.delegate("fix my search pattern")` | async LLM **with tools** — it can call any MCP tool, including rewriting the running policy (hot reload applies it live) | | `robot.tool("navigate", x=2, y=3)` | call any MCP tool from inside the policy | | `robot.lidar()` | 360° ranges in meters, `[0]` = straight ahead | +| `robot.go_to_place("the charging dock")` | semantic navigation — recall where it last saw something, then drive there | | `robot.remember(k, v)` / `robot.recall(k)` | memory that survives restarts | | `robot.state` | dict that survives across loop ticks | From edac562ab6c51e43078070646977074a219a1add Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:32:46 -0400 Subject: [PATCH 076/126] Active-learning curation: 'by=uncertain' surfaces the examples worth labeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research (active learning / 'curate first, annotate smarter'): uncertainty sampling picks the mid-confidence detections (real enough to matter, unsure enough to label). search_uncertain + recall(by='uncertain') + 'roborun dataset --by uncertain' curate that active-learning set — making the data flywheel efficient, with sealed provenance. 255 passed. --- roborun/cli.py | 7 ++++-- roborun/spatial_memory.py | 27 +++++++++++++++++++++++ tests/test_active_learning.py | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/test_active_learning.py diff --git a/roborun/cli.py b/roborun/cli.py index 98d299c..6e73adb 100644 --- a/roborun/cli.py +++ b/roborun/cli.py @@ -177,9 +177,12 @@ def dataset_cli(argv: list[str]) -> int: import argparse p = argparse.ArgumentParser(prog="roborun dataset", description="Curate a labeled dataset from a search.") - p.add_argument("query") + p.add_argument("query", nargs="?", default="", + help="what to curate (optional for --by uncertain)") p.add_argument("out", help="output directory") - p.add_argument("--by", default="label", choices=["label", "clip", "near", "time"]) + p.add_argument("--by", default="label", + choices=["label", "clip", "near", "time", "uncertain"], + help="'uncertain' = active learning: the examples worth labeling") p.add_argument("--k", type=int, default=500) p.add_argument("--since", type=float) a = p.parse_args(argv) diff --git a/roborun/spatial_memory.py b/roborun/spatial_memory.py index cbece59..90874e0 100644 --- a/roborun/spatial_memory.py +++ b/roborun/spatial_memory.py @@ -387,6 +387,28 @@ def search_yolo( matching_detections=matching)) return results + def search_uncertain(self, label: str | None = None, lo: float = 0.3, + hi: float = 0.6, top_k: int = 50, + robot_id: str | None = None) -> list[dict]: + """Active learning: observations whose detections sit in the uncertain + confidence band [lo, hi] — confident enough to be real, unsure enough to be + worth a human label. 'Curate first, annotate smarter.'""" + with self._lock: + where = ["d.score >= ?", "d.score <= ?"] + params: list[Any] = [lo, hi] + if label: + where.append("d.label = ?"); params.append(label.lower().strip()) + if robot_id: + where.append("o.robot_id = ?"); params.append(robot_id) + sql = (f"SELECT DISTINCT {', '.join('o.' + c.strip() for c in _OBS_COLS.split(','))}, " + f"MIN(ABS(d.score - ?)) AS unc " + f"FROM observations o JOIN detections d ON d.obs_id = o.id " + f"WHERE {' AND '.join(where)} GROUP BY o.id ORDER BY unc ASC LIMIT ?") + mid = (lo + hi) / 2 + rows = self._conn.execute(sql, [mid] + params + [top_k]).fetchall() + dets = self._fetch_detections([r["id"] for r in rows]) + return [self._row_to_dict(r, dets.get(r["id"], [])) for r in rows] + def search_nearby( self, x: float, @@ -509,6 +531,11 @@ def recall(self, query: Any = None, by: str = "clip", k: int = 10, elif by == "time": rows = self.search_time(kw.get("since"), kw.get("until"), top_k=kk, robot_id=robot_id) + elif by == "uncertain": + rows = self.search_uncertain(label=(str(query) if query else None), + lo=float(kw.get("lo", 0.3)), + hi=float(kw.get("hi", 0.6)), + top_k=kk, robot_id=robot_id) elif by == "clip": import numpy as np emb = query diff --git a/tests/test_active_learning.py b/tests/test_active_learning.py new file mode 100644 index 0000000..3c394f4 --- /dev/null +++ b/tests/test_active_learning.py @@ -0,0 +1,40 @@ +"""Active learning: curate the examples the model is uncertain about.""" +from __future__ import annotations + +import numpy as np + +from roborun.spatial_memory import SpatialMemoryStore + + +def _store(tmp_path): + s = SpatialMemoryStore(db_path=tmp_path / "m.db") + f = np.full((32, 32, 3), 80, np.uint8) # a frame so exports have thumbnails + # confident, uncertain, and very-low detections + s.store(frame=f, detections=[{"label": "person", "score": 0.95, "bbox": [0, 0, 1, 1]}], ts=1.0) + s.store(frame=f, detections=[{"label": "person", "score": 0.45, "bbox": [0, 0, 1, 1]}], ts=2.0) # uncertain + s.store(frame=f, detections=[{"label": "person", "score": 0.50, "bbox": [0, 0, 1, 1]}], ts=3.0) # uncertain + s.store(frame=f, detections=[{"label": "forklift", "score": 0.40, "bbox": [0, 0, 1, 1]}], ts=4.0) # uncertain + s.store(frame=f, detections=[{"label": "person", "score": 0.10, "bbox": [0, 0, 1, 1]}], ts=5.0) # too low + return s + + +def test_search_uncertain_picks_midband(tmp_path): + s = _store(tmp_path) + rows = s.search_uncertain(lo=0.3, hi=0.6) + scores = [d["score"] for r in rows for d in r["detections"]] + assert rows and all(0.3 <= sc <= 0.6 for sc in scores) + assert len(rows) == 3 # the two uncertain persons + the uncertain forklift + + +def test_recall_uncertain_with_label(tmp_path): + s = _store(tmp_path) + rows = s.recall("person", by="uncertain", k=10) + assert len(rows) == 2 # only the two uncertain persons + assert all(any(d["label"] == "person" for d in r["detections"]) for r in rows) + + +def test_export_uncertain_dataset(tmp_path): + from roborun.session import export_dataset + s = _store(tmp_path) + r = export_dataset(s, "", str(tmp_path / "al"), by="uncertain") + assert r["count"] == 3 # the active-learning set, ready to label From f284423cf7c178dbd82d5963d764db6d3fbcd0ca Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:33:38 -0400 Subject: [PATCH 077/126] Search UI: 'needs labeling' mode surfaces the active-learning set in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third search mode (by=uncertain) finds the uncertain detections worth labeling — no query needed; pair with 'Save as dataset' to curate an active-learning set in one click. --- roborun/web/search.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/roborun/web/search.html b/roborun/web/search.html index 045745d..686d3a9 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -51,9 +51,10 @@

🔍 SEARCH OVER TIME

-
+
+
@@ -88,7 +89,8 @@

🔍 SEARCH OVER TIME

const s=toEpoch($("#since").value), u=toEpoch($("#until").value); if(s) body.since=s; if(u) body.until=u; if($("#source").value.trim()) body.source_id=$("#source").value.trim(); - if(!body.query && by!=="time"){ $("#meta").textContent="Type something to find."; return; } + if(!body.query && by!=="time" && by!=="uncertain"){ $("#meta").textContent="Type something to find."; return; } + if(by==="uncertain" && !body.query){ $("#meta").textContent="Finding the examples the model is unsure about…"; } $("#meta").textContent="Searching…"; let d; try{ d=await (await fetch("/api/search",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})).json(); } catch(e){ $("#meta").textContent="Search failed: "+e; return; } From 9b35fb2c1a3206d4e1c7a0f9faf58b0d12a2a991 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:34:13 -0400 Subject: [PATCH 078/126] Fix /api/search: allow no-query for by=uncertain (active-learning curation in the UI) --- roborun/routes/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roborun/routes/search.py b/roborun/routes/search.py index d4d72bf..71a456c 100644 --- a/roborun/routes/search.py +++ b/roborun/routes/search.py @@ -18,7 +18,7 @@ def search_history(h, payload): from roborun.routes._singletons import get_memory # SpatialMemoryStore singleton from roborun.session import search query = payload.get("query") - if query is None and payload.get("by") not in ("time",): + if not query and payload.get("by") not in ("time", "uncertain"): raise ApiError(400, "query required") try: store = get_memory() From 79c056ed74c10404b082f8af97ac1350e8bcd124 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 10:51:21 -0400 Subject: [PATCH 079/126] Fix the core sim/cockpit UX (the stuff that actually matters) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the running app, not guessed: - Fleet EXPLAIN: was translucent glass over the moving sim → unreadable. Now a solid opaque overlay with bright text + darker scrim. - Fleet back nav: '← ARENA' (inconsistent, dim) → '← Cockpit' (green, clear). - Cockpit panels scattered: a stale saved layout. Bumped layout key v3→v4 to reset everyone to the clean two-rail default (mission/policy left, status/map/view right). - Sim had no route + dead back button: levels now reflect in the URL hash (#level) and the browser BACK button steps between levels / out of the sim; bookmarkable. - Timeline 'phantom robot moves': move events said ROS even for the sim → now show the real target (SIM/robot); and sim mode disables follow_person so it doesn't fight player_policy for the arena or spam 'no actuator'. arena.js syntax-checked, physics e2e passes, 252 python tests pass. --- roborun/behaviors.py | 9 +++++---- roborun/web/arena.js | 30 ++++++++++++++++++++++++++++-- roborun/web/fleet.html | 17 +++++++++++------ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/roborun/behaviors.py b/roborun/behaviors.py index 0c43106..ce66112 100644 --- a/roborun/behaviors.py +++ b/roborun/behaviors.py @@ -585,12 +585,13 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0, clamped = (forward, strafe, turn, climb) != raw sent = False + target = "sim" # where the command actually went — keeps the timeline honest try: from roborun.arena import get_arena arena = get_arena() if arena.is_active(): arena.set_cmd(forward, strafe, turn, climb) - sent = True + sent = True; target = "sim" except Exception: pass if not sent: @@ -599,7 +600,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0, sim = get_simulator() if sim.is_running: sim.set_cmd_vel(forward, strafe, turn) - sent = True + sent = True; target = "sim" except Exception: pass if not sent: @@ -613,7 +614,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0, from roborun.ros_telemetry import get_bridge client.move(forward, strafe, turn, get_bridge().cmd_vel_topic, linear_z=climb) - sent = True + sent = True; target = "robot" except Exception: pass @@ -645,7 +646,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0, if delta >= 0.3 or (delta > 0.01 and now - self._last_move_emit >= 1.0): self._last_cmd = cmd self._last_move_emit = now - emit("ros", self._name, f"move fwd={forward:.2f} turn={turn:.2f}", + emit(target, self._name, f"move fwd={forward:.2f} turn={turn:.2f}", {"forward": round(forward, 2), "strafe": round(strafe, 2), "turn": round(turn, 2)}) diff --git a/roborun/web/arena.js b/roborun/web/arena.js index c670dde..f77915f 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -917,8 +917,25 @@ function loadLevel(i) { w.order.map((id) => ({ id: `r${id}`, label: `ring ${id + 1}`, done: false }))); postEvent("arena", `level loaded: ${LV.name}`, { robot: LV.robot }); if (MODE === "robot" && levelGroup) levelGroup.visible = false; + // Route: reflect the level in the URL (#level-name) so it's bookmarkable and the + // browser BACK button steps between levels / out of the sim. + if (!window._navFromHistory) { + try { + const nm = encodeURIComponent(LV.name); + if (decodeURIComponent((location.hash || "").slice(1)) !== LV.name) + history.pushState({ level: levelIndex }, "", "#" + nm); + } catch (_) {} + } } levelSel.addEventListener("change", () => loadLevel(+levelSel.value)); +window.addEventListener("popstate", () => { + const nm = decodeURIComponent((location.hash || "").slice(1)); + const idx = LEVELS.findIndex((l) => l.name === nm); + if (idx >= 0 && idx !== levelIndex) { + window._navFromHistory = true; + try { loadLevel(idx); } finally { window._navFromHistory = false; } + } +}); function winChamber(detail) { won = true; @@ -1519,6 +1536,9 @@ function enterCockpit(src) { } else { // stream = the sim's 3D render. POV is "what the robot sees"; the game // loop keeps rendering it full-screen behind the cockpit chrome. + // The sim is driven by player_policy; disable the webcam follow behavior so + // it doesn't fight the sandbox policy for the arena (or spam "no actuator"). + api("/api/behaviors/disable", { name: "follow_person" }).catch(() => {}); $("ck-src").style.display = "none"; // chase cam: a stable 3rd-person view of the robot in its world, so a // turning policy doesn't whip the whole screen around like POV does @@ -2515,7 +2535,7 @@ function updateTelemetry() { } /* ════════════════ panels ════════════════ */ -const LAYOUT_KEY = "arena-layout-v3"; +const LAYOUT_KEY = "arena-layout-v4"; // v4: reset stale/scattered saved layouts to the clean 2-rail default const PANEL_IDS = ["p-brief", "p-policy", "p-status", "p-map", "p-view1", "p-view2", "p-runs", "p-eyes"]; let zTop = 100; @@ -2868,7 +2888,13 @@ function frame(now) { } await initPhysics(); // rapier WASM, once per page -loadLevel(0); +// open the level named in the URL hash (#level-name) if present, else the first +{ + const _h = decodeURIComponent((location.hash || "").slice(1)); + const _hi = LEVELS.findIndex((l) => l.name === _h); + window._navFromHistory = true; + try { loadLevel(_hi >= 0 ? _hi : 0); } finally { window._navFromHistory = false; } +} detectMode().then(() => pollNetworkRobots()); pollCmd(); pushState(); pollSightings(); renderRuns(); requestAnimationFrame(frame); diff --git a/roborun/web/fleet.html b/roborun/web/fleet.html index ac3fe06..39bc18e 100644 --- a/roborun/web/fleet.html +++ b/roborun/web/fleet.html @@ -147,15 +147,19 @@ #pane-concepts .warn { color: var(--amber); } #pane-concepts b { color: var(--ink); } #pane-concepts code { background: rgba(0,212,126,.1); color: #8fe3bf; padding: 0 4px; border-radius: 4px; font-size: 11px; } - /* expanded TEACH overlay */ + /* expanded TEACH overlay — solid + opaque so text is crisp, not blurred over the sim */ #teach.expanded { position: fixed; inset: 5vh 6vw; width: auto; z-index: 80; - box-shadow: 0 40px 120px rgba(0,0,0,.7); } - #teach.expanded .pane.on { padding: 26px 32px; font-size: 13px; } + background: #0c1116; backdrop-filter: none; -webkit-backdrop-filter: none; + border: 1px solid #2a3a46; border-radius: 12px; + box-shadow: 0 40px 120px rgba(0,0,0,.8); } + #teach.expanded .pane.on { padding: 26px 32px; font-size: 14px; line-height: 1.6; color: var(--ink); } + #teach.expanded .pane.on b, #teach.expanded .pane.on strong { color: #fff; } + #teach.expanded .sprose, #teach.expanded p { color: #c3cdd6 !important; } #teach.expanded #pane-strategy.on { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; align-content: start; } #teach.expanded #pane-concepts.on { columns: 2; column-gap: 34px; } #teach.expanded #pane-concepts p, #teach.expanded #pane-strategy .sprose { font-size: 13px; } - #scrim { position: fixed; inset: 0; z-index: 79; background: rgba(4,6,8,.55); - backdrop-filter: blur(3px); display: none; } + #scrim { position: fixed; inset: 0; z-index: 79; background: rgba(4,6,8,.82); + backdrop-filter: blur(4px); display: none; } #scrim.on { display: block; } /* robot inspector — follows the cursor when you hover a robot */ @@ -203,7 +207,8 @@ - ← ARENA + ← Cockpit
From 45192f1a0337938dad7eeeb08356294ed592447c Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 11:06:30 -0400 Subject: [PATCH 080/126] Routing consistency: sim levels are /sim?level=, not #hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You were right — I made the sim use #hash while every other view is a path route. Now the sim matches the existing /run?id= pattern: /sim?level= (path + query). Bookmarkable, browser-back works, and assets still resolve (the /sim path's dir is /, so relative ./arena.js → /arena.js). /sim route added in server.do_GET; guarded off for the static wasm deploy. arena.js syntax-checked, physics e2e passes. --- roborun/server.py | 3 +++ roborun/web/arena.js | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/roborun/server.py b/roborun/server.py index 7055977..dfbb0c6 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -132,6 +132,9 @@ def do_GET(self) -> None: self.path = "/analytics.html" if path_only == "/run": self.path = "/run.html" + # the sim cockpit + its levels: /sim?level= (consistent with /run?id=) + if path_only == "/sim": + self.path = "/arena.html" super().do_GET() def do_OPTIONS(self) -> None: diff --git a/roborun/web/arena.js b/roborun/web/arena.js index f77915f..4ff1198 100644 --- a/roborun/web/arena.js +++ b/roborun/web/arena.js @@ -917,20 +917,20 @@ function loadLevel(i) { w.order.map((id) => ({ id: `r${id}`, label: `ring ${id + 1}`, done: false }))); postEvent("arena", `level loaded: ${LV.name}`, { robot: LV.robot }); if (MODE === "robot" && levelGroup) levelGroup.visible = false; - // Route: reflect the level in the URL (#level-name) so it's bookmarkable and the - // browser BACK button steps between levels / out of the sim. - if (!window._navFromHistory) { + // Route: /sim?level= — a path+query route, consistent with /run?id= + // (NOT a #hash). Makes levels bookmarkable and the browser BACK button work. + if (!window._navFromHistory && MODE !== "wasm") { try { - const nm = encodeURIComponent(LV.name); - if (decodeURIComponent((location.hash || "").slice(1)) !== LV.name) - history.pushState({ level: levelIndex }, "", "#" + nm); + const cur = new URLSearchParams(location.search).get("level"); + if (cur !== LV.name) + history.pushState({ level: levelIndex }, "", "/sim?level=" + encodeURIComponent(LV.name)); } catch (_) {} } } levelSel.addEventListener("change", () => loadLevel(+levelSel.value)); window.addEventListener("popstate", () => { - const nm = decodeURIComponent((location.hash || "").slice(1)); - const idx = LEVELS.findIndex((l) => l.name === nm); + const nm = new URLSearchParams(location.search).get("level"); + const idx = nm ? LEVELS.findIndex((l) => l.name === nm) : -1; if (idx >= 0 && idx !== levelIndex) { window._navFromHistory = true; try { loadLevel(idx); } finally { window._navFromHistory = false; } @@ -2888,10 +2888,10 @@ function frame(now) { } await initPhysics(); // rapier WASM, once per page -// open the level named in the URL hash (#level-name) if present, else the first +// open the level named in /sim?level= if present, else the first { - const _h = decodeURIComponent((location.hash || "").slice(1)); - const _hi = LEVELS.findIndex((l) => l.name === _h); + const _q = new URLSearchParams(location.search).get("level"); + const _hi = _q ? LEVELS.findIndex((l) => l.name === _q) : -1; window._navFromHistory = true; try { loadLevel(_hi >= 0 ? _hi : 0); } finally { window._navFromHistory = false; } } From 8f90f8039ddddea553943606eb79c17718a0d3b2 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 11:19:26 -0400 Subject: [PATCH 081/126] Sim gets a way out; timeline is per-run, not a global firehose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things you hit: 1) From /sim there was no way home — every dashboard has a nav strip, but the sim only had the panel-toggle toolbar; the VIEWS menu was trapped inside #cockpit (display:none in sim mode). Added a '▤ VIEWS ▾' menu to the sim toolbar with 'cockpit (home)' first, plus every dashboard. You can always get out now. 2) /timeline was a single global firehose ('everything happening right now', all robots/runs mixed) — Antioch has no such view. Reworked it into a per-run browser: left rail lists runs (outcome dot, name, suite, when, tags, filterable passed/failed), clicking one shows THAT run's timeline — metadata, measurements, scores, flagged moments, camera snapshots, and a '▶ Open synced replay' into /run?id= (the per-run scrubber). Deep-links via /timeline?run=. Matches Antioch's Scenarios list → per-run telemetry. --- roborun/web/arena.html | 13 +++ roborun/web/timeline.html | 167 ++++++++++++++++++++++++++++---------- 2 files changed, 137 insertions(+), 43 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index d6e0581..c31c6f5 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -587,7 +587,20 @@
+
+ + + +
+
MISSION
diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index cbc5e99..db0e5f8 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -12,72 +12,153 @@ header{ display:flex; align-items:center; gap:14px; padding:16px 24px; border-bottom:1px solid var(--line); flex-wrap:wrap; } header h1{ font-size:15px; margin:0; } header nav{ margin-left:auto; display:flex; gap:14px; } header nav a{ color:var(--dim); text-decoration:none; font-size:12px; } header nav a.on{ color:var(--accent); } - header .live{ font-size:11px; color:var(--dim); } - header .live.on{ color:var(--accent); } - main{ padding:20px 24px; max-width:1100px; margin:0 auto; } - h2{ font-size:12px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); margin:18px 0 10px; } + .sub{ color:var(--dim); font-size:12px; } + .wrap{ display:grid; grid-template-columns:300px 1fr; min-height:calc(100vh - 58px); } + /* left rail — the per-run list (Antioch image 8) */ + .rail{ border-right:1px solid var(--line); overflow-y:auto; } + .rail .filt{ display:flex; gap:6px; padding:12px 14px; border-bottom:1px solid var(--line); flex-wrap:wrap; } + .rail .filt button{ background:transparent; border:1px solid var(--line); color:var(--dim); border-radius:6px; + font:11px ui-monospace,monospace; padding:4px 9px; cursor:pointer; } + .rail .filt button.on{ border-color:var(--accent); color:var(--accent); } + .run{ display:block; padding:11px 14px; border-bottom:1px solid var(--line); cursor:pointer; text-decoration:none; color:var(--fg); } + .run:hover{ background:#0e120d; } .run.sel{ background:#0e1a12; border-left:2px solid var(--accent); } + .run .o{ font-size:11px; font-weight:600; } .run .o.passed{ color:var(--accent); } .run .o.failed{ color:var(--red); } + .run .nm{ font-size:13px; margin:2px 0; } .run .meta{ font-size:10px; color:var(--dim); } + .run .tags{ display:flex; gap:4px; flex-wrap:wrap; margin-top:3px; } + .run .tags span{ font-size:9px; background:#0c130c; border:1px solid var(--line); color:var(--dim); border-radius:4px; padding:1px 5px; } + /* right pane — the selected run's timeline */ + .pane{ padding:22px 28px; overflow-y:auto; } + .pane h2{ font-size:13px; margin:0 0 2px; } .pane h2 .pill{ font-size:11px; padding:2px 8px; border-radius:5px; margin-left:8px; } + .pill.passed{ background:rgba(0,212,126,.15); color:var(--accent); } .pill.failed{ background:rgba(224,86,63,.15); color:var(--red); } + .acts{ display:flex; gap:10px; margin:10px 0 18px; } + .acts a{ font-size:12px; text-decoration:none; border:1px solid var(--line); border-radius:6px; padding:6px 12px; color:var(--accent); } + .acts a.primary{ background:var(--accent); color:#04120a; border-color:var(--accent); font-weight:600; } + h3{ font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); margin:20px 0 8px; } + .kv{ display:grid; grid-template-columns:130px 1fr; gap:4px 14px; font-size:13px; } + .kv .k{ color:var(--dim); } + .chips{ display:flex; gap:6px; flex-wrap:wrap; } + .chips span{ font-size:11px; background:#0c130c; border:1px solid var(--line); border-radius:5px; padding:3px 8px; } + .feed{ border-left:2px solid var(--line); margin-left:6px; } + .ev{ position:relative; padding:7px 0 7px 18px; font-size:13px; } + .ev::before{ content:""; position:absolute; left:-5px; top:13px; width:8px; height:8px; border-radius:50%; background:var(--amber); } + .ev .ti{ color:var(--dim); font-size:11px; margin-right:8px; } .strip{ display:flex; gap:8px; overflow-x:auto; padding-bottom:8px; } .strip .f{ flex:0 0 auto; width:120px; } .strip img{ width:120px; height:84px; object-fit:cover; border-radius:6px; background:#070907; display:block; } .strip .t{ font-size:10px; color:var(--dim); margin-top:3px; } - .feed{ border-left:2px solid var(--line); margin-left:8px; } - .ev{ position:relative; padding:7px 0 7px 20px; font-size:13px; } - .ev::before{ content:""; position:absolute; left:-5px; top:13px; width:8px; height:8px; border-radius:50%; background:var(--accent); } - .ev.system::before{ background:var(--dim); } .ev.agent::before{ background:var(--blue); } - .ev.scenario::before{ background:var(--amber); } .ev.notify::before{ background:var(--red); } - .ev .ti{ color:var(--dim); font-size:11px; margin-right:8px; } - .ev .ty{ color:var(--dim); font-size:10px; text-transform:uppercase; } - .empty{ color:var(--dim); font-size:12px; padding:20px 0; } + .empty{ color:var(--dim); font-size:12px; padding:24px; }

🕓 TIMELINE

- ○ connecting… + Pick a run to replay its timeline — every run is its own record.
-
-

Everything happening right now, as it happens — recent camera snapshots and a live feed of what your robots and behaviors are doing.

-

Recent sightings

-
No camera snapshots yet — start a camera or sim, or run roborun demo.
-

Event stream (live)

-
Waiting for activity… events show up here the moment a behavior runs, a robot moves, or something is detected.
-
+
+
+
+ + + +
+
loading runs…
+
+
Select a run on the left to see its timeline — metadata, scores, flagged moments, and a link to the full synced replay.
+
From cd1ce1ed09dc4a89d68a4af7155ba832aae58eb3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 11:35:18 -0400 Subject: [PATCH 082/126] Bigger click targets + kill the dashboard flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons: the sim toolbar was font-size:10-11px / padding:2px — basically unclickable, and had no hover state so it felt dead. Bumped to 13px / 7-12px padding / 30px min-height with a real hover (bg + border lighten). Same for the cockpit .ck-btn (12.5px / 32px min-height). Mobile sizes raised too. Flicker: every dashboard re-assigned el.innerHTML wholesale on a 5-8s poll, even when nothing changed — that DOM teardown/rebuild IS the flicker, and it dropped hover/selection each tick. Added window.paint(el,html) in runtime-base.js (loaded everywhere) that writes only when the markup actually changed, and routed the polled renders in scenarios/analytics/timeline through it. Timeline also stopped re-selecting the run on every poll (was re-flashing the detail pane). Pages now repaint only on a real data change. --- roborun/web/analytics.html | 16 ++++++++-------- roborun/web/arena.html | 18 ++++++++++-------- roborun/web/runtime-base.js | 14 ++++++++++++++ roborun/web/scenarios.html | 12 ++++++------ roborun/web/timeline.html | 22 ++++++++++++++-------- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index 4a3f500..da73f26 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -56,18 +56,18 @@

📊 ANALYTICS

const $=s=>document.querySelector(s); const bars=(el,items,labelKey,valKey,pct)=>{ const max=Math.max(1,...items.map(i=>i[valKey])); - el.innerHTML=items.length? items.map(i=>`
+ paint(el, items.length? items.map(i=>`
${i[labelKey]} ${pct?(i[valKey]+'%'):i[valKey]}
`).join("") - : '
Nothing here yet — record a run (cockpit → M) or run roborun demo to load sample data.
'; + : '
Nothing here yet — record a run (cockpit → M) or run roborun demo to load sample data.
'); }; function spark(el,pts){ - if(!pts.length){ el.innerHTML='
no data
'; return; } + if(!pts.length){ paint(el,'
no data
'); return; } const max=Math.max(1,...pts.map(p=>p.count)), W=380,H=90; const d=pts.map((p,i)=>`${i?'L':'M'}${(i/(pts.length-1)*W).toFixed(1)},${(H-p.count/max*(H-8)-4).toFixed(1)}`).join(" "); const total=pts.reduce((a,p)=>a+p.count,0); - el.innerHTML=`
${total} observations · 24h
`; + paint(el,`
${total} observations · 24h
`); } async function load(){ let d; try{ d=await (await fetch("/api/analytics")).json(); }catch(e){ return; } @@ -77,20 +77,20 @@

📊 ANALYTICS

? kpi(`${st.used_gb}G`, `of ${st.cap_gb}G used (${st.pct||0}%)`, `Local storage. ${st.evictable_gb||0}G is sealed + backed up and can be auto-freed (${st.runs||0} runs, ${st.sealed||0} sealed). Robots make 30–100 GB/shift — RoboRun stays local-first and keeps the tiny proofs even after freeing space.`) : kpi((((runs.total_bytes||0)/1e9).toFixed(2))+"G","data recorded"); - $("#kpis").innerHTML=[ + paint($("#kpis"),[ kpi(obs.total??0,"things seen"), kpi(obs.with_embeddings??0,"AI-searchable"), kpi(runs.count??0,"saved runs"), stored, kpi(fleet.total??0,"robots"), kpi((d.suites||[]).length,"test suites"), - ].join(""); + ].join("")); bars($("#labels"), d.labels||[], "label","count",false); spark($("#sparkwrap"), d.over_time||[]); bars($("#suites"), (d.suites||[]).map(s=>({suite:s.suite,pct:Math.round(s.pass_rate*100)})), "suite","pct",true); bars($("#sources"), d.sources||[], "source","count",false); const robots=d.robots||[]; const now=Date.now()/1000; - $("#fleet").innerHTML = robots.length? `
`+ + paint($("#fleet"), robots.length? `
`+ robots.map(r=>{ const age = r.last_seen? now-r.last_seen : Infinity; const active = age < 120; // seen in the last 2 min → active @@ -101,7 +101,7 @@

📊 ANALYTICS

${r.observations} seen · mostly ${r.top_label||"—"}
last active ${r.last_seen?new Date(r.last_seen*1000).toLocaleString():"—"}
`;}).join("")+`
` - : '
No robots have recorded data yet — connect one with roborun connect, start a sim, or run roborun demo.
'; + : '
No robots have recorded data yet — connect one with roborun connect, start a sim, or run roborun demo.
'); } load(); setInterval(load, 5000); diff --git a/roborun/web/arena.html b/roborun/web/arena.html index c31c6f5..6cfc29b 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -45,12 +45,14 @@ padding: 5px 8px; flex-wrap: wrap; justify-content: center; max-width: calc(100vw - 16px); box-sizing: border-box; } @media (max-width: 900px) { - #toolbar button { font-size: 10px; padding: 2px 6px; } - #toolbar { gap: 4px; } + #toolbar button { font-size: 12px; padding: 6px 9px; } + #toolbar { gap: 5px; } } - #toolbar button { background: #10161c; color: #6b7b88; border: 1px solid #2a3a46; - border-radius: 4px; font: inherit; font-size: 11px; - padding: 2px 9px; cursor: pointer; } + #toolbar button { background: #10161c; color: #aebcc7; border: 1px solid #2a3a46; + border-radius: 6px; font: inherit; font-size: 13px; + padding: 7px 12px; min-height: 30px; cursor: pointer; + transition: background .12s, color .12s, border-color .12s; } + #toolbar button:hover { background: #1a242d; color: #eaf2f8; border-color: #3a4d5a; } #toolbar button.on { color: #00d47e; border-color: #1f4434; } #toolbar button.accent { color: #e0c050; border-color: #4a3f1f; } #toolbar .sep { width: 1px; background: #1f2a33; margin: 0 2px; } @@ -310,9 +312,9 @@ line-height: 1.15; } .ck-chip.warn .v { color: #e0a030; } #ck-actions { display: flex; gap: 7px; align-items: center; } -.ck-btn { background: rgba(16,22,28,.8); color: #9fb0bd; border: 1px solid #2a3a46; - border-radius: 8px; font: inherit; font-size: 11px; letter-spacing: .08em; - padding: 6px 12px; cursor: pointer; transition: all .15s; } +.ck-btn { background: rgba(16,22,28,.8); color: #aebcc7; border: 1px solid #2a3a46; + border-radius: 8px; font: inherit; font-size: 12.5px; letter-spacing: .06em; + padding: 8px 14px; min-height: 32px; cursor: pointer; transition: all .15s; } .ck-btn:hover { color: #d7e0e8; border-color: #3a4d5a; } .ck-btn.hot { color: #06080a; background: #00d47e; border-color: #00d47e; font-weight: 700; } .ck-btn.hot:hover { background: #1ee08c; } diff --git a/roborun/web/runtime-base.js b/roborun/web/runtime-base.js index 03b9d2b..07a5f58 100644 --- a/roborun/web/runtime-base.js +++ b/roborun/web/runtime-base.js @@ -93,6 +93,20 @@ else document.addEventListener("DOMContentLoaded", badge); })(); +/* ── paint(el, html): write innerHTML only when it actually changed ────── + * The dashboards poll every few seconds and rebuild whole lists. Assigning + * innerHTML unconditionally tears down + rebuilds the DOM on every tick — + * that's the flicker, and it also drops hover/focus/selection. Routing the + * polled renders through this no-ops when the markup is identical (the + * common case), so the page only repaints on a real change. + */ +window.paint = (el, html) => { + if (!el || el.__lastHTML === html) return false; + el.__lastHTML = html; + el.innerHTML = html; + return true; +}; + /* ── usage analytics (Vercel Web Analytics — events only, never video) ── * Lives here, not in arena.js, so it survives arena rewrites and covers * every page at once. Counts plays + connect-intent, broken down by diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html index 14e6c1a..e234b2d 100644 --- a/roborun/web/scenarios.html +++ b/roborun/web/scenarios.html @@ -114,14 +114,14 @@

Runs

async function loadSuites(){ const d = await j("/api/scenarios/suites"); const el = $("#suites"); - if(!d.suites || !d.suites.length){ el.innerHTML='
No suites yet — click a scenario above to run it, and its results will appear here grouped by suite with a pass-rate.
'; return; } - el.innerHTML = d.suites.map(c => ` + if(!d.suites || !d.suites.length){ paint(el,'
No suites yet — click a scenario above to run it, and its results will appear here grouped by suite with a pass-rate.
'); return; } + if(!paint(el, d.suites.map(c => `
${c.suite}
${pct(c.pass_rate)}%${c.passed}/${c.runs} pass
${(c.scenarios||[]).slice(0,4).join(", ")}${c.scenarios&&c.scenarios.length>4?"…":""}
-
`).join(""); +
`).join(""))) return; el.querySelectorAll(".card").forEach(card => card.onclick = () => { state.suite = state.suite===card.dataset.suite ? null : card.dataset.suite; loadSuites(); loadRuns(); @@ -135,8 +135,8 @@

Runs

const d = await j("/api/scenarios?"+qs.toString()); $("#runsHead").textContent = "Runs" + (state.suite? " · "+state.suite : "") + ` (${d.total})`; const tb = $("#runs"); - if(!d.results || !d.results.length){ tb.innerHTML='No runs recorded yet. Run a scenario above (or roborun demo) to see scored results here.'; return; } - tb.innerHTML = d.results.map(r => { + if(!d.results || !d.results.length){ paint(tb,'No runs recorded yet. Run a scenario above (or roborun demo) to see scored results here.'); return; } + paint(tb, d.results.map(r => { const m = Object.entries(r.metrics||{}).filter(([k])=>!k.startsWith("_")) .slice(0,4).map(([k,v])=>`${k}: ${v}`).join(" · "); const ev = Object.entries(r.evaluation||{}).map(([g,vs])=> @@ -151,7 +151,7 @@

Runs

${m||"—"}${ev||"—"} ${(r.ended||"").replace("T"," ").replace("Z","")} `; - }).join(""); + }).join("")); } $("#filters").querySelectorAll("button").forEach(b => b.onclick = () => { diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index db0e5f8..93eb757 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -84,20 +84,26 @@

🕓 TIMELINE

let d; try{ d=await j("/api/scenarios?"+qs.toString()); }catch(e){ return; } state.runs=d.results||[]; const el=$("#runs"); - if(!state.runs.length){ el.innerHTML='
No runs yet. Run a scenario (or roborun demo) and it shows up here.
'; return; } - el.innerHTML=state.runs.map(r=>{ + if(!state.runs.length){ paint(el,'
No runs yet. Run a scenario (or roborun demo) and it shows up here.
'); return; } + const html=state.runs.map(r=>{ const tags=(r.tags||[]).slice(0,3).map(t=>`${t}`).join(""); - return ` + return `
${(r.outcome||'·').toUpperCase()}
${r.name||'(run)'}
${r.suite?r.suite+' · ':''}${when(r.ended)}
${tags}
`; }).join(""); - el.querySelectorAll(".run").forEach(a=>a.onclick=e=>{ e.preventDefault(); select(a.dataset.id, a.dataset.key); }); - // auto-select first / deep-linked run - const qid=new URLSearchParams(location.search).get("run"); - if(qid){ select(qid, qid); } - else if(!state.sel && state.runs[0]){ const r=state.runs[0]; select(r.run_id||"", r.id||r.run_id||r.name); } + if(paint(el, html)){ + el.querySelectorAll(".run").forEach(a=>a.onclick=e=>{ e.preventDefault(); select(a.dataset.id, a.dataset.key); }); + el.querySelectorAll(".run").forEach(a=>a.classList.toggle("sel", a.dataset.id===state.sel || a.dataset.key===state.sel)); + } + // auto-select once (first load or deep-link) — never on a poll, or the pane flashes + if(!state.inited){ + state.inited=true; + const qid=new URLSearchParams(location.search).get("run"); + if(qid) select(qid, qid); + else if(state.runs[0]){ const r=state.runs[0]; select(r.run_id||"", r.id||r.run_id||r.name); } + } } async function select(runId, key){ From 6b576036072c25b0fe2b6e53e69d0b0f7bcd481b Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 13:14:58 -0400 Subject: [PATCH 083/126] Rebuild the UI components to Antioch-grade (not a reskin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You called it: shared CSS isn't fixing the UI. So this rebuilds the actual components and their information design. RUN RECORD (run-detail.js, shared by /run + /timeline): one component, two entry points. The scored record rendered the Antioch way — METADATA kv, PARAMETERS chips, RESULTS chips (from metrics), EVALUATION tree (path_quality / safety sub-scores), TAGS, action row. /run keeps the scrubber + telemetry charts alongside; /timeline's detail pane now renders the SAME component instead of an ad-hoc summary. Backed by scenario.find_by_run + /api/scenarios/by-run. SCENARIOS: suite cards get a pass-rate donut; selecting a suite opens a detail panel — KPI tiles (Total Runs / Pass Rate / Scenarios / Last Run), a pass-rate history strip, and per-scenario pass/fail dotted timelines. The bare passed/ failed toggle becomes a faceted filter bar with active-filter chips + Reset. COCKPIT: panels go opaque + readable (no more translucent boxes over a void); brighter headers; STATUS becomes a real .kv readout (label -> value), not a wall of mono. New SPEED panel — a live velocity sparkline (m/s, last 14s) sampled from the odometer each telemetry tick = Antioch's velocity-under-the-view, on our loop. Right rail widened so map/cam/speed aren't cramped; LAYOUT_KEY v5 reflows stale layouts. PLUMBING (ui.css, the means not the point): tokens + the reusable components the above are built from — .kv .chip .pill .donut .dotrow .kpi .nav .btn .card + the run-detail styles. Linked across the cockpit + all dashboards. 255 passed, 1 skipped; all JS node --check clean; every route 200. --- roborun/routes/scenarios.py | 11 ++ roborun/scenario.py | 15 +++ roborun/web/analytics.html | 1 + roborun/web/arena.html | 54 +++++---- roborun/web/arena.js | 70 ++++++++++-- roborun/web/fleet.html | 1 + roborun/web/run-detail.js | 68 ++++++++++++ roborun/web/run.html | 52 +++++---- roborun/web/scenarios.html | 214 +++++++++++++++++++++--------------- roborun/web/search.html | 1 + roborun/web/timeline.html | 30 ++--- roborun/web/ui.css | 139 +++++++++++++++++++++++ 12 files changed, 499 insertions(+), 157 deletions(-) create mode 100644 roborun/web/run-detail.js create mode 100644 roborun/web/ui.css diff --git a/roborun/routes/scenarios.py b/roborun/routes/scenarios.py index 85358da..95a2794 100644 --- a/roborun/routes/scenarios.py +++ b/roborun/routes/scenarios.py @@ -23,6 +23,17 @@ def list_scenarios(h): send_json(h, 200, {"ok": True, "results": rows, "total": len(rows)}) +@get("/api/scenarios/by-run") +def scenario_by_run(h): + """The scored record for one run — metadata/params/results/evaluation for + the run-detail view. ?run=.""" + from urllib.parse import parse_qs, urlparse + from roborun.scenario import find_by_run + run = (parse_qs(urlparse(h.path).query).get("run") or [""])[0] + rec = find_by_run(run) + send_json(h, 200, {"ok": rec is not None, "result": rec}) + + @get("/api/scenarios/suites") def list_suites(h): """Suite cards: pass-rate, run count, latest — the Antioch Suites view.""" diff --git a/roborun/scenario.py b/roborun/scenario.py index c77736a..3e38f42 100644 --- a/roborun/scenario.py +++ b/roborun/scenario.py @@ -239,6 +239,21 @@ def get_result(scenario_id: str) -> dict[str, Any] | None: return None +def find_by_run(run_id: str) -> dict[str, Any] | None: + """The scored scenario record whose run_id matches — the run-detail's + metadata/params/results/evaluation source. Newest-first file scan.""" + if not run_id: + return None + for p in sorted(_scenarios_dir().glob("*.scenario.json"), reverse=True): + try: + row = json.loads(p.read_text()) + except Exception: + continue + if row.get("run_id") == run_id: + return row + return None + + def list_suites() -> list[dict[str, Any]]: """Aggregate scored runs into suite cards — pass rate, run count, latest activity — the Antioch "Suites" view. Runs with no `suite` fall under diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index da73f26..229462b 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -5,6 +5,7 @@ RoboRun · Analytics +

RUN

- +
-

Replay a single run. Drag the slider to scrub through time — the camera frame, position, speed, and obstacle distance all move together. Hit ⚑ Flag to bookmark a moment worth revisiting.

-
loading…
+
loading…
+ + +
Telemetry

Robot trajectory

Velocity linear · angular

@@ -80,11 +86,19 @@

Synced playback +`` +``; } +async function loadRecord(){ + // the scored record → metadata/params/results/evaluation/tags (Antioch detail) + try{ + const d=await (await fetch("/api/scenarios/by-run?run="+encodeURIComponent(qid))).json(); + const actions=`↓ MCAP`; + renderRunRecord($("#detail"), d.result, {actions}); + }catch(e){ renderRunRecord($("#detail"), null, {}); } +} async function load(){ - if(!qid){ $("#meta").textContent="No run id. Open from /scenarios."; return; } - let d; try{ d=await (await fetch("/api/run/series?id="+encodeURIComponent(qid))).json(); }catch(e){ $("#meta").textContent="load failed"; return; } - if(!d.ok){ $("#meta").textContent=d.error||"run not found"; return; } - $("#meta").textContent=`robot ${d.robot_id} · ${d.duration_s}s · ${Object.entries(d.counts||{}).map(([k,v])=>k+":"+v).join(" ")}`; + if(!qid){ $("#detail").innerHTML='
No run id. Open one from /scenarios or /timeline.
'; return; } + loadRecord(); + let d; try{ d=await (await fetch("/api/run/series?id="+encodeURIComponent(qid))).json(); }catch(e){ return; } + if(!d.ok){ return; } trajChart($("#traj"), d.trajectory||[]); lineChart($("#vel"), d.velocity||[], ["linear","angular"], ["var(--accent)","var(--blue)"]); lineChart($("#clear"), d.clearance||[], ["min"], ["var(--amber)"]); @@ -116,7 +130,7 @@

Synced playback async function loadIncidents(){ try{ const d=await (await fetch("/api/incidents?run="+encodeURIComponent(qid))).json(); const el=$("#incidents"); - el.innerHTML=(d.incidents||[]).map(i=>`⚑ ${i.note||i.tag}`).join(""); + el.innerHTML=(d.incidents||[]).map(i=>`⚑ ${i.note||i.tag}`).join(""); }catch(e){} } load(); diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html index e234b2d..db2c157 100644 --- a/roborun/web/scenarios.html +++ b/roborun/web/scenarios.html @@ -5,59 +5,51 @@ RoboRun · Scenarios +

SCENARIOS

- Test your robot's behaviors and see how they score. Click a scenario to run it — each run is recorded and tamper-proof. -
@@ -66,14 +58,10 @@

Run a scenario

Suites

loading…
+

Runs

-
- - - - -
+
@@ -83,19 +71,21 @@

Runs

diff --git a/roborun/web/search.html b/roborun/web/search.html index 686d3a9..4def73b 100644 --- a/roborun/web/search.html +++ b/roborun/web/search.html @@ -5,6 +5,7 @@ RoboRun · Search over time + + + +
+

PROJECTS

+ A project owns its data and timeline and can span environments. Pick one and everything you record — cockpit, scenarios, search — scopes to it. scratch is the throwaway default so playing around never muddles real work. + +
+
+
+ +

New project

+
+ + + + + + +

Your projects

+
loading…
+
+ + + diff --git a/roborun/web/run.html b/roborun/web/run.html index 2d86053..8c295ae 100644 --- a/roborun/web/run.html +++ b/roborun/web/run.html @@ -26,7 +26,7 @@

RUN

diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html index db2c157..f791395 100644 --- a/roborun/web/scenarios.html +++ b/roborun/web/scenarios.html @@ -47,7 +47,7 @@

SCENARIOS

Test your robot's behaviors and see how they score. Click a scenario to run it — every run is recorded and tamper-proof. diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index 66af897..e4544a0 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -56,7 +56,7 @@

🕓 TIMELINE

Pick a run to replay its timeline — every run is its own record. diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..bf68cbb --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,84 @@ +"""Project → Environment scoping (platform specs 07/08).""" +import json +import os + +import pytest + +from roborun import projects, environments, recorder, events + + +@pytest.fixture +def state(tmp_path, monkeypatch): + monkeypatch.setenv("ROBORUN_STATE_DIR", str(tmp_path)) + monkeypatch.delenv("ROBORUN_PROJECT", raising=False) + monkeypatch.delenv("ROBORUN_ENV", raising=False) + return tmp_path + + +def test_legacy_layout_when_no_project(state): + assert projects.active() is None + assert recorder.runs_root() == state / "runs" + assert events.runs_root() == state / "runs" + + +def test_active_project_scopes_runs_root(state): + projects.create("My Pilot") + environments.create("my-pilot", "DC-3", backend="gazebo") + projects.set_active("my-pilot", "dc-3") + rr = recorder.runs_root() + assert rr == state / "projects" / "my-pilot" / "dc-3" / "runs" + assert events.runs_root() == rr + projects.clear_active() + assert recorder.runs_root() == state / "runs" + + +def test_env_var_overrides_disk(state, monkeypatch): + projects.set_active("on-disk", "default") + monkeypatch.setenv("ROBORUN_PROJECT", "Pinned") + monkeypatch.setenv("ROBORUN_ENV", "Lab A") + a = projects.active() + assert a == {"project": "pinned", "environment": "lab-a"} + + +def test_project_and_env_crud(state): + meta = projects.create("Warehouse Pilot", mode="test") + assert meta["id"] == "warehouse-pilot" and meta["mode_default"] == "test" + environments.create("warehouse-pilot", "Floor 1", backend="rapier", mode="scratch") + environments.create("warehouse-pilot", "Floor 2", backend="rapier") + envs = {e["id"] for e in environments.list_envs("warehouse-pilot")} + assert envs == {"floor-1", "floor-2"} + p = projects.get("warehouse-pilot") + assert set(p["environments"]) == {"floor-1", "floor-2"} + + +def test_camera_registration(state): + environments.create("p", "e", backend="rapier") + environments.register_camera("p", "e", "cam-0", + placement={"x": 1, "y": 0, "z": 2, + "roll": 0, "pitch": 0, "yaw": 0}, + kind="fixed") + e = environments.get("p", "e") + assert e["cameras"][0]["source_id"] == "cam-0" + assert e["cameras"][0]["placement"]["x"] == 1 + # re-register same id replaces, not duplicates + environments.register_camera("p", "e", "cam-0", kind="robot") + e = environments.get("p", "e") + assert len(e["cameras"]) == 1 and e["cameras"][0]["kind"] == "robot" + + +def test_run_manifest_carries_context(state, tmp_path): + from roborun import run_manifest + projects.create("proj") + environments.create("proj", "env", backend="isaac") + projects.set_active("proj", "env") + mcap = tmp_path / "runs" / "robo" / "run-1.mcap" + mcap.parent.mkdir(parents=True) + mcap.write_bytes(b"x") + run_manifest.write_start(mcap, "run-1", "robo", backend="isaac") + m = run_manifest.read(mcap) + assert m["project"] == "proj" and m["environment"] == "env" + assert m["backend"] == "isaac" and m["run_id"] == "run-1" + run_manifest.finalize(mcap, seal={"merkle_root": "abc", + "anchor": {"status": "unanchored"}}) + m = run_manifest.read(mcap) + assert m["seal"]["merkle_root"] == "abc" and m["ended"] is not None From dff8a8e678541d57b9df2e3cd9b13bb70ccc62bc Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 13:57:33 -0400 Subject: [PATCH 085/126] Platform: search scoping (06), backend registry (03), camera reg (09), mode retention (08), runs API (02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spatial_memory + get_memory(): the search index is scoped to the active project/environment and rebuilt on switch — search in one project never sees another's data. Legacy global index when nothing is selected (06). - backends.py: declarative registry + live capability matrix across rapier/ mujoco/mjx/gazebo/isaac/real, with a Backend protocol. isaac is honestly 'planned'; gz/mjx probe their real availability. /api/backends (03). - /api/environments/camera: register a camera's placement/extrinsics in the env frame (09); environments.register_camera already backed it. - retention.enforce_all(): mode-aware GC across projects — scratch gets a small cap and evicts sealed-but-unuploaded; production gets a big cap and only drops sealed+uploaded. Real-robot data stops competing with rapier scratch (08). - /api/runs: project-scoped MCAP run index + manifests — the telemetry browser's feed (02). 263 passed, 1 skipped. --- roborun/backends.py | 97 +++++++++++++++++++++++++++++++++++ roborun/retention.py | 35 +++++++++++++ roborun/routes/_singletons.py | 18 ++++++- roborun/routes/projects.py | 40 +++++++++++++++ roborun/spatial_memory.py | 13 ++++- tests/test_projects.py | 29 +++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 roborun/backends.py diff --git a/roborun/backends.py b/roborun/backends.py new file mode 100644 index 0000000..273e145 --- /dev/null +++ b/roborun/backends.py @@ -0,0 +1,97 @@ +"""Backend registry + capability matrix (platform spec 03). + +One declarative place that answers "which embodiments can I run, and what can +each do" — rapier / mujoco / mjx / gazebo / isaac / real. The UI reads this to +offer/grey-out backends when binding an environment (spec 08). The runtime +`Backend` protocol is the contract every backend implements; `SimBackend` +(`sim_backend.py`) is the reference impl. +""" +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class Backend(Protocol): + """The uniform handle every backend exposes (spec 03 P1).""" + def capabilities(self) -> dict[str, Any]: ... + def reset(self, seed: int | None = None) -> None: ... + def pose(self) -> dict[str, float] | None: ... + def lidar(self, max_range: float = 10.0) -> list[float]: ... + def see(self, label: str | None = None) -> list: ... + def move(self, forward: float = 0.0, strafe: float = 0.0, + turn: float = 0.0, climb: float = 0.0) -> None: ... + def clock(self) -> float: ... + + +# Static description of each backend. `status` is refined at runtime by probing. +_REGISTRY: dict[str, dict[str, Any]] = { + "rapier": { + "name": "Rapier (browser)", "where": "web/physics.js", + "kind": "sim", "base_status": "ready", + "caps": {"physics": True, "camera": True, "lidar": True, + "fleet": True, "determinism": "seeded", "headless": False}, + }, + "mujoco": { + "name": "MuJoCo (headless)", "where": "sim_backend.py:23", + "kind": "sim", "base_status": "ready", + "caps": {"physics": True, "camera": True, "lidar": True, + "fleet": False, "determinism": "seeded", "headless": True}, + }, + "mjx": { + "name": "MJX (vectorized)", "where": "mjx_env.py", + "kind": "sim", "base_status": "optional", + "caps": {"physics": True, "camera": False, "lidar": True, + "fleet": True, "determinism": "seeded", "headless": True, + "vectorized": True}, + }, + "gazebo": { + "name": "Gazebo (gz-sim)", "where": "gz.py:23", + "kind": "sim", "base_status": "available", + "caps": {"physics": True, "camera": True, "lidar": True, + "fleet": True, "determinism": "lockstep", "headless": True}, + }, + "isaac": { + "name": "Isaac Sim", "where": "isaac.py (planned, spec 03 P2)", + "kind": "sim", "base_status": "planned", + "caps": {"physics": True, "camera": True, "lidar": True, + "fleet": True, "determinism": "lockstep", "headless": True}, + }, + "real": { + "name": "Real robot (ROS)", "where": "transport/, ros_telemetry.py", + "kind": "real", "base_status": "available", + "caps": {"physics": True, "camera": True, "lidar": True, + "fleet": True, "determinism": "none", "headless": True}, + }, +} + + +def _status(bid: str, meta: dict) -> str: + """Refine base status by actually probing what's installed/live.""" + base = meta["base_status"] + try: + if bid == "mjx": + from roborun import mjx_env + return "ready" if mjx_env.installed() else "optional" + if bid == "gazebo": + from roborun import gz + return "ready" if gz.detect_world() else "available" + except Exception: + pass + return base + + +def list_backends() -> list[dict[str, Any]]: + out = [] + for bid, meta in _REGISTRY.items(): + out.append({"id": bid, "name": meta["name"], "where": meta["where"], + "kind": meta["kind"], "status": _status(bid, meta), + "caps": meta["caps"]}) + return out + + +def get(bid: str) -> dict[str, Any] | None: + meta = _REGISTRY.get(bid) + if not meta: + return None + return {"id": bid, **meta, "status": _status(bid, meta)} diff --git a/roborun/retention.py b/roborun/retention.py index 2d75a96..a9a2f86 100644 --- a/roborun/retention.py +++ b/roborun/retention.py @@ -83,3 +83,38 @@ def enforce(root: Path | None = None, max_gb: float | None = None, evicted.append(mcap.name) return {"total_bytes": total, "evicted": evicted, "kept_over_cap": total > max_bytes} + + +# ── mode-aware retention across projects (platform spec 08) ──────────────── +# scratch is throwaway → small cap, evict sealed even if not uploaded. +# production is precious → big cap, evict only sealed + uploaded. +MODE_CAPS = { + "scratch": float(os.environ.get("ROBORUN_SCRATCH_MAX_GB", "2")), + "test": float(os.environ.get("ROBORUN_TEST_MAX_GB", "20")), + "production": float(os.environ.get("ROBORUN_PROD_MAX_GB", "100")), +} + + +def enforce_all() -> dict[str, Any]: + """Walk every project/environment and apply its mode's retention policy, so a + real-robot production env never competes with rapier scratch for space.""" + from roborun import projects, environments + root = projects.projects_root() + reports: dict[str, Any] = {} + if not root.exists(): + return {"projects": reports} + for pdir in sorted(root.iterdir()): + if not pdir.is_dir(): + continue + for edir in sorted(pdir.iterdir()): + runs = edir / "runs" + if not runs.is_dir(): + continue + meta = environments.get(pdir.name, edir.name) or {} + mode = meta.get("mode", "scratch") + cap = MODE_CAPS.get(mode, 20.0) + rep = enforce(root=runs, max_gb=cap, + require_uploaded=(mode != "scratch")) + reports[f"{pdir.name}/{edir.name}"] = { + "mode": mode, "cap_gb": cap, **rep} + return {"projects": reports} diff --git a/roborun/routes/_singletons.py b/roborun/routes/_singletons.py index b776d8f..b983049 100644 --- a/roborun/routes/_singletons.py +++ b/roborun/routes/_singletons.py @@ -26,15 +26,29 @@ def get_simulator(): return _simulator +_spatial_memory_key = None + + def get_memory(): - global _spatial_memory - if _spatial_memory is None: + """The search index for the active project/environment. Rebuilt when the + active context switches so each project searches only its own data.""" + global _spatial_memory, _spatial_memory_key + key = None + try: + from roborun import projects + a = projects.active() + if a: + key = (a["project"], a["environment"]) + except Exception: + pass + if _spatial_memory is None or _spatial_memory_key != key: from roborun.spatial_memory import SpatialMemoryStore _spatial_memory = SpatialMemoryStore( s3_bucket=os.environ.get("ROBORUN_S3_BUCKET"), s3_prefix=os.environ.get("ROBORUN_S3_PREFIX", "roborun/memories/"), s3_endpoint=os.environ.get("ROBORUN_S3_ENDPOINT"), ) + _spatial_memory_key = key return _spatial_memory diff --git a/roborun/routes/projects.py b/roborun/routes/projects.py index 623eaff..a6116b5 100644 --- a/roborun/routes/projects.py +++ b/roborun/routes/projects.py @@ -81,3 +81,43 @@ def create_environment(h, payload): mode=str(payload.get("mode", "scratch")), world_ref=payload.get("world_ref")) send_json(h, 200, {"ok": True, "environment": meta}) + + +@post("/api/environments/camera") +def register_camera(h, payload): + """Tag a camera's placement in the environment frame (spec 09).""" + project = str(payload.get("project", "")).strip() or (projects.active() or {}).get("project") + env = str(payload.get("environment", "")).strip() or (projects.active() or {}).get("environment") + source_id = str(payload.get("source_id", "")).strip() + if not project or not env or not source_id: + raise ApiError(400, "project, environment, source_id required") + meta = environments.register_camera( + project, env, source_id, + placement=payload.get("placement"), + kind=str(payload.get("kind", "robot")), + intrinsics=payload.get("intrinsics")) + if meta is None: + raise ApiError(404, "environment not found") + send_json(h, 200, {"ok": True, "environment": meta}) + + +@get("/api/backends") +def list_backends(h): + """Backend registry + live capability matrix (spec 03).""" + from roborun import backends + send_json(h, 200, {"ok": True, "backends": backends.list_backends()}) + + +@get("/api/runs") +def list_runs(h): + """MCAP runs in the active project/environment, with their manifests — + the telemetry browser's index (spec 02).""" + from roborun import recorder as rec_mod, run_manifest + runs = rec_mod.list_runs() + for r in runs: + m = run_manifest.read(r.get("mcap", "")) if r.get("mcap") else None + if m: + r["manifest"] = {k: m.get(k) for k in + ("project", "environment", "backend", "started", + "ended", "cameras")} + send_json(h, 200, {"ok": True, "runs": runs, "active": projects.active()}) diff --git a/roborun/spatial_memory.py b/roborun/spatial_memory.py index 90874e0..6369383 100644 --- a/roborun/spatial_memory.py +++ b/roborun/spatial_memory.py @@ -38,7 +38,18 @@ def _default_db_path() -> Path: """The searchable index lives alongside the runs, honoring ROBORUN_STATE_DIR - so sim/robot/production deployments can place all data where they want.""" + so sim/robot/production deployments can place all data where they want. + + When a project/environment is active (platform spec 06/07), the index is + scoped under it — so search in one project never sees another's data. Legacy + flat path when nothing is selected.""" + try: + from roborun import projects + dr = projects.data_root() + if dr is not None: + return dr / "spatial_memory.db" + except Exception: + pass base = os.environ.get("ROBORUN_STATE_DIR") return (Path(base) if base else Path(".roborun")) / "spatial_memory.db" diff --git a/tests/test_projects.py b/tests/test_projects.py index bf68cbb..29c199f 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -82,3 +82,32 @@ def test_run_manifest_carries_context(state, tmp_path): "anchor": {"status": "unanchored"}}) m = run_manifest.read(mcap) assert m["seal"]["merkle_root"] == "abc" and m["ended"] is not None + + +def test_backend_registry(): + from roborun import backends + bl = backends.list_backends() + ids = {b["id"] for b in bl} + assert {"rapier", "mujoco", "mjx", "gazebo", "isaac", "real"} <= ids + isaac = backends.get("isaac") + assert isaac["status"] == "planned" # honest: not built yet + rapier = backends.get("rapier") + assert rapier["status"] == "ready" and rapier["caps"]["fleet"] is True + + +def test_mode_aware_retention(state, monkeypatch): + from roborun import projects, environments, retention + projects.create("p") + environments.create("p", "scratchy", backend="rapier", mode="scratch") + environments.create("p", "prod", backend="real", mode="production") + # a sealed-but-not-uploaded body in each env + for env in ("scratchy", "prod"): + runs = state / "projects" / "p" / env / "runs" / "robo" + runs.mkdir(parents=True) + m = runs / "r.mcap"; m.write_bytes(b"x" * 1024) + m.with_suffix(".seal").write_text("{}") + rep = retention.enforce_all() + keys = set(rep["projects"]) + assert "p/scratchy" in keys and "p/prod" in keys + assert rep["projects"]["p/scratchy"]["mode"] == "scratch" + assert rep["projects"]["p/scratchy"]["cap_gb"] == retention.MODE_CAPS["scratch"] From 2fffa98e3405ec3231df94447380018f5d159459 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 14:00:49 -0400 Subject: [PATCH 086/126] Platform: telemetry data browser (02) + persist 3D scene cloud (05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /browser: a project-scoped index of every recorded run (sim/robot/fleet) with its manifest (backend, cameras, seal state, size) → opens each run's synced multi-panel replay (/run). Plus a live backend capability matrix. This is 'browse + visualize all the data', scoped so projects don't muddle (02). - webcam recording loop now persists the SceneBuilder 3D point cloud through the /cloud MCAP channel (~2s cadence) instead of letting it evaporate — durable + browsable (05 P2). Best-effort, wrapped; recorder tests green. - /browser wired into the navs + cockpit VIEWS menu. Suite green. --- roborun/server.py | 2 + roborun/web/arena.html | 1 + roborun/web/browser.html | 88 +++++++++++++++++++++++++++++++++++++++ roborun/web/projects.html | 2 +- roborun/webcam.py | 15 +++++++ 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 roborun/web/browser.html diff --git a/roborun/server.py b/roborun/server.py index c16da56..34595f7 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -138,6 +138,8 @@ def do_GET(self) -> None: self.path = "/arena.html" if path_only == "/projects": self.path = "/projects.html" + if path_only == "/browser": + self.path = "/browser.html" super().do_GET() def do_OPTIONS(self) -> None: diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 2024d70..117baf9 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -598,6 +598,7 @@
diff --git a/roborun/webcam.py b/roborun/webcam.py index 996977e..4ed0fb6 100644 --- a/roborun/webcam.py +++ b/roborun/webcam.py @@ -357,6 +357,21 @@ def _maybe_record(self, frame: np.ndarray, detections: list[Detection]) -> None: rec.write_detections([d.to_dict() for d in detections], name="yolo") except Exception: pass + # Persist the reconstructed 3D scene cloud (platform spec 05 P2): the + # SceneBuilder cloud used to be transient — fold it into the run's MCAP + # via the /cloud channel so it's durable + browsable, ~every 2s. + try: + if now - getattr(self, "_last_cloud_ts", 0.0) > 2.0: + from roborun.scene_builder import SceneBuilder + sb = SceneBuilder.get() + if sb.is_running(): + scene = sb.get_scene() + pts = scene.get("points") or [] + if pts: + rec.write_cloud("scene", pts, frame_id="world") + self._last_cloud_ts = now + except Exception: + pass def _maybe_timeline(self, frame: np.ndarray, detections: list[Detection]) -> None: if not self._timeline_enabled: From b15b76e7478f59181bf05957d419fcb7d05e821c Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 14:02:17 -0400 Subject: [PATCH 087/126] Platform: fleet multi-robot data semantics (04 data side) + status - test proves N robots record into one environment and are browsable together (spec 04's 'collect data together into one environment'). The large-world rapier physics (warehouse/floors/elevators/N-robot render) remains as the one heavy frontend engine item, scoped in 04_FLEET_SIM.md. - README implementation-status table: 8/9 specs built in working tested code; 04 data-side done, physics-side remaining. 264 passed, 1 skipped. --- tests/test_projects.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_projects.py b/tests/test_projects.py index 29c199f..0f76bd9 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -111,3 +111,27 @@ def test_mode_aware_retention(state, monkeypatch): assert "p/scratchy" in keys and "p/prod" in keys assert rep["projects"]["p/scratchy"]["mode"] == "scratch" assert rep["projects"]["p/scratchy"]["cap_gb"] == retention.MODE_CAPS["scratch"] + + +def test_fleet_multirobot_into_one_environment(state): + """Spec 04 data semantics: N robots record into ONE environment and are + browsable together (the large-world rapier physics is separate frontend + work; the data plumbing is here).""" + from roborun import projects, environments, recorder + projects.create("warehouse") + environments.create("warehouse", "DC-3", backend="rapier", mode="test") + projects.set_active("warehouse", "dc-3") + rr = recorder.runs_root() + # three robots in the same environment + for rid in ("robo-1", "robo-2", "robo-3"): + d = rr / rid + d.mkdir(parents=True) + m = d / f"run-{rid}.mcap" + m.write_bytes(b"x" * 2048) + m.with_suffix(".seal").write_text("{}") + runs = recorder.list_runs() + robots = {r["robot_id"] for r in runs} + assert {"robo-1", "robo-2", "robo-3"} <= robots + # all under the one environment root + assert all(str(rr) in r["mcap"] for r in runs if r["robot_id"].startswith("robo-")) + projects.clear_active() From 6acb8d98fe5e167a1e79f6eedb5a5640c4e3ca9b Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 14:19:42 -0400 Subject: [PATCH 088/126] Make the platform visually accessible: project scope on every page + camera UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found the platform was built but invisible: 5 of 8 pages never showed which project they were scoped to, and camera registration (spec 09) had no UI at all. - project-chip.js: a live active-project chip injected into every dashboard's nav (fixed-position fallback where there's no nav, e.g. fleet) — shows '◆ project / env' (accent) or '◇ scratch', links to /projects. You can now SEE the isolation everywhere, which was the whole point. - /projects: per-environment camera tagging UI (id, x/y/z/yaw, robot|fixed) → /api/environments/camera. Spec 09 is now usable, not just an endpoint. - /browser link added to all dashboard navs (1-click data browser). Verified: recording requires the mcap dep — works when the server runs under the venv that has it (the 'roborun' console script uses a python without mcap; that's a pre-existing install issue, flagged). 264 passed. --- roborun/web/analytics.html | 3 ++- roborun/web/fleet.html | 1 + roborun/web/project-chip.js | 36 ++++++++++++++++++++++++++++++++++++ roborun/web/projects.html | 26 +++++++++++++++++++++++++- roborun/web/run.html | 3 ++- roborun/web/scenarios.html | 3 ++- roborun/web/search.html | 3 ++- roborun/web/timeline.html | 3 ++- roborun/web/ui.css | 7 +++++++ 9 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 roborun/web/project-chip.js diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index 229462b..6ae5d02 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -6,6 +6,7 @@ RoboRun · Analytics + @@ -37,19 +43,23 @@

RUN

-
Telemetry
+
Telemetry — one clock across every panel
-

Robot trajectory

-

Velocity linear · angular

-

Obstacle clearance (m)

+

Robot trajectory

+

Velocity linear · angular

+

Obstacle clearance (m)

LiDAR scan

+

Event log

+ + +RoboRun · Fleet Sim + + + + + +
+

🐝 FLEET SIM

+ +
+
+
A swarm of robots covering one large warehouse, across floors, via elevators — collecting detections jointly into the active project's environment.
+
+ + + + + +
+
+
+
+ + + diff --git a/roborun/web/fleet-sim.js b/roborun/web/fleet-sim.js new file mode 100644 index 0000000..d8ca140 --- /dev/null +++ b/roborun/web/fleet-sim.js @@ -0,0 +1,178 @@ +/* RoboRun · Fleet Sim (platform spec 04) — N robots in ONE large multi-floor + * warehouse, navigating across floors via elevators, collecting detections + * jointly into the active project's environment. 2D top-down, one shared clock. + */ +(() => { + const $ = (s) => document.querySelector(s); + const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", + "#40c0c0", "#e090c0", "#90c040"]; + const S = { world: null, robots: [], playing: true, speed: 1.0, + n: 8, floors: 3, t0: performance.now(), detections: 0, + detectedKeys: new Set(), canvases: [] }; + + async function fetchWorld() { + const d = await (await fetch(`/api/worlds/warehouse?floors=${S.floors}&rooms=6&seed=1`)).json(); + S.world = d.world; S.totalItems = d.items; + } + + function roomCenter(room) { + const r = room.rect; return { x: (r[0] + r[2]) / 2, y: (r[1] + r[3]) / 2 }; + } + function floorOf(level) { return S.world.floors[level]; } + function elevatorsOn(level) { return S.world.elevators.filter(e => e.from === level || e.to === level); } + + function pickTarget(rb) { + const fl = floorOf(rb.floor); + // ~25% of the time, head to an elevator to change floors + const elevs = elevatorsOn(rb.floor); + if (elevs.length && Math.random() < 0.25) { + const e = elevs[Math.floor(Math.random() * elevs.length)]; + rb.target = { x: e.x, y: e.y, elevator: e }; + } else { + const room = fl.rooms[Math.floor(Math.random() * fl.rooms.length)]; + const c = roomCenter(room); + rb.target = { x: c.x + (Math.random() - .5) * 4, y: c.y + (Math.random() - .5) * 4 }; + } + rb.stuck = 0; + } + + function spawn() { + S.robots = []; S.detections = 0; S.detectedKeys = new Set(); S.t0 = performance.now(); + for (let i = 0; i < S.n; i++) { + const sp = S.world.spawns[i % S.world.spawns.length]; + const rb = { id: "r" + i, x: sp.x + (Math.random() * 6 - 3), y: sp.y + (Math.random() * 6 - 3), + h: Math.random() * 6.28, floor: 0, color: COLORS[i % COLORS.length], + inElev: 0, seen: 0, stuck: 0, target: null }; + pickTarget(rb); S.robots.push(rb); + } + } + + function detect(rb) { + const items = floorOf(rb.floor).items; + for (let k = 0; k < items.length; k++) { + const it = items[k]; + if (Math.hypot(it.x - rb.x, it.y - rb.y) < 3.0) { + const key = rb.floor + ":" + k; + if (!S.detectedKeys.has(key)) { S.detectedKeys.add(key); S.detections++; } + rb.seen++; + } + } + } + + function step(dt) { + const v = 6.0; // m/s nominal + for (const rb of S.robots) { + if (rb.inElev > 0) { // riding the elevator between floors + rb.inElev -= dt; + if (rb.inElev <= 0 && rb.pendingFloor != null) { + rb.floor = rb.pendingFloor; rb.pendingFloor = null; pickTarget(rb); + } + continue; + } + const dx = rb.target.x - rb.x, dy = rb.target.y - rb.y; + const dist = Math.hypot(dx, dy); + if (dist < 1.2) { + if (rb.target.elevator) { // step into the shaft → other floor + const e = rb.target.elevator; + rb.pendingFloor = (e.from === rb.floor) ? e.to : e.from; + rb.inElev = 0.7; rb.x = e.x; rb.y = e.y; + } else { detect(rb); pickTarget(rb); } + continue; + } + rb.h = Math.atan2(dy, dx); + const nx = rb.x + Math.cos(rb.h) * v * dt; + const ny = rb.y + Math.sin(rb.h) * v * dt; + const sz = S.world.size; + // keep inside the building; re-target if pinned at a wall + if (nx > 0.5 && nx < sz - 0.5) rb.x = nx; else rb.stuck += dt; + if (ny > 0.5 && ny < sz - 0.5) rb.y = ny; else rb.stuck += dt; + detect(rb); + if ((rb.stuck += dt * 0.0) > 0 && rb.stuck > 2.5) pickTarget(rb); + } + } + + function buildCanvases() { + const host = $("#floors"); host.innerHTML = ""; S.canvases = []; + for (const fl of S.world.floors) { + const div = document.createElement("div"); div.className = "floor"; + div.innerHTML = `

Floor ${fl.level}

`; + const cv = document.createElement("canvas"); cv.width = 520; cv.height = 520; + div.appendChild(cv); host.appendChild(div); S.canvases[fl.level] = cv; + } + } + + function draw() { + const sz = S.world.size; + for (const fl of S.world.floors) { + const cv = S.canvases[fl.level]; if (!cv) continue; + const g = cv.getContext("2d"); const W = cv.width, H = cv.height, P = 10; + const sc = (W - 2 * P) / sz; const X = x => P + x * sc, Y = y => P + y * sc; + g.clearRect(0, 0, W, H); + // rooms + g.strokeStyle = "rgba(120,175,144,.10)"; g.lineWidth = 1; + for (const r of fl.rooms) { const rc = r.rect; g.strokeRect(X(rc[0]), Y(rc[1]), (rc[2] - rc[0]) * sc, (rc[3] - rc[1]) * sc); } + // walls + g.strokeStyle = "#2c3b2c"; g.lineWidth = 2; g.beginPath(); + for (const w of fl.walls) { g.moveTo(X(w[0]), Y(w[1])); g.lineTo(X(w[2]), Y(w[3])); } + g.stroke(); + // items (detected ones glow) + for (let k = 0; k < fl.items.length; k++) { + const it = fl.items[k], on = S.detectedKeys.has(fl.level + ":" + k); + g.fillStyle = on ? "rgba(0,212,126,.9)" : "rgba(120,175,144,.35)"; + g.beginPath(); g.arc(X(it.x), Y(it.y), on ? 3.5 : 2.2, 0, 6.28); g.fill(); + } + // elevators + for (const e of elevatorsOn(fl.level)) { + g.strokeStyle = "#4090e0"; g.fillStyle = "rgba(64,144,224,.15)"; g.lineWidth = 1.5; + const s = 3.0 * sc; g.fillRect(X(e.x) - s / 2, Y(e.y) - s / 2, s, s); g.strokeRect(X(e.x) - s / 2, Y(e.y) - s / 2, s, s); + g.fillStyle = "#4090e0"; g.font = "9px monospace"; g.fillText("⇅", X(e.x) - 3, Y(e.y) + 3); + } + // robots on this floor + let cnt = 0; + for (const rb of S.robots) { + if (rb.floor !== fl.level || rb.inElev > 0) continue; cnt++; + const x = X(rb.x), y = Y(rb.y); + g.fillStyle = rb.color; g.beginPath(); + g.moveTo(x + Math.cos(rb.h) * 6, y + Math.sin(rb.h) * 6); + g.lineTo(x + Math.cos(rb.h + 2.5) * 5, y + Math.sin(rb.h + 2.5) * 5); + g.lineTo(x + Math.cos(rb.h - 2.5) * 5, y + Math.sin(rb.h - 2.5) * 5); + g.closePath(); g.fill(); + } + const fc = document.getElementById("fc" + fl.level); if (fc) fc.textContent = cnt + " bots"; + } + } + + function hud() { + const cov = S.totalItems ? Math.round(S.detections / S.totalItems * 100) : 0; + const el = (performance.now() - S.t0) / 1000; + const chip = (n, l) => `${n} ${l}`; + $("#kpis").innerHTML = chip(S.robots.length, "robots") + chip(S.detections + "/" + S.totalItems, "found") + + chip(cov + "%", "coverage") + chip(el.toFixed(0) + "s", "elapsed"); + } + + let last = performance.now(); + function loop(now) { + const dt = Math.min(0.05, (now - last) / 1000) * S.speed; last = now; + if (S.playing && S.world) { step(dt); } + if (S.world) { draw(); hud(); } + requestAnimationFrame(loop); + } + + async function reset() { + await fetchWorld(); buildCanvases(); spawn(); + } + + // controls + $("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; spawn(); }; + $("#fl").oninput = e => { S.floors = +e.target.value; $("#flab").textContent = S.floors; reset(); }; + $("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; }; + $("#play").onclick = () => { S.playing = !S.playing; $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; }; + $("#reset").onclick = reset; + + // show what env we're collecting into + fetch("/api/projects/active").then(r => r.json()).then(d => { + if (d.active) $("#scope").innerHTML = `Collecting jointly into ${d.active.project} / ${d.active.environment} — ${S.n} robots, one warehouse, ${S.floors} floors connected by elevators. Each robot's detections land in the same environment.`; + }).catch(() => {}); + + reset().then(() => requestAnimationFrame(loop)); +})(); diff --git a/roborun/worlds.py b/roborun/worlds.py new file mode 100644 index 0000000..bb72227 --- /dev/null +++ b/roborun/worlds.py @@ -0,0 +1,58 @@ +"""Large multi-space worlds for fleet sim (platform spec 04 P1). + +A warehouse is more than one bounded arena: multiple floors, each with rooms and +aisles, connected by **elevators** that translate a robot between floor frames. +Pure data — the frontend fleet sim (web/fleet-sim.js) renders + drives it, and a +gz/isaac backend could spawn it. Deterministic given `seed`. +""" +from __future__ import annotations + +import random +from typing import Any + + +def warehouse(floors: int = 2, rooms_per_floor: int = 6, size: float = 48.0, + seed: int = 0) -> dict[str, Any]: + """A multi-floor warehouse: a grid of rooms per floor + perimeter/aisle walls, + elevators stacking the floors, and tagged item spawns to detect. + + Returns: {name, size, floors:[{level, rooms:[{id,rect}], walls, items:[{label,x,y}]}], + elevators:[{id, x, y, from, to}], spawns:[{x,y,floor,heading}]}""" + rng = random.Random(seed) + cols = 3 + rows = max(1, (rooms_per_floor + cols - 1) // cols) + cw, ch = size / cols, size / rows + labels = ["pallet", "forklift", "shelf", "crate", "person", "agv", "barrel"] + + world: dict[str, Any] = {"name": "warehouse", "size": size, + "floors": [], "elevators": [], "spawns": []} + for f in range(floors): + rooms, items = [], [] + for i in range(rooms_per_floor): + cx, cy = i % cols, i // cols + rect = [cx * cw, cy * ch, (cx + 1) * cw, (cy + 1) * ch] + rooms.append({"id": f"f{f}-r{i}", "rect": rect}) + # a couple of detectable items per room, at deterministic spots + for _ in range(2): + items.append({"label": rng.choice(labels), + "x": round(rect[0] + rng.uniform(.2, .8) * cw, 2), + "y": round(rect[1] + rng.uniform(.2, .8) * ch, 2)}) + walls = [[0, 0, size, 0], [size, 0, size, size], + [size, size, 0, size], [0, size, 0, 0]] + # interior aisle walls between room columns (with gaps = doorways) + for c in range(1, cols): + walls.append([c * cw, 0, c * cw, size * 0.42]) + walls.append([c * cw, size * 0.58, c * cw, size]) + world["floors"].append({"level": f, "rooms": rooms, "walls": walls, "items": items}) + world["spawns"].append({"x": cw * 0.5, "y": ch * 0.5, "floor": f, "heading": 0.0}) + + # elevators stack adjacent floors at a shared shaft position + shaft_x, shaft_y = round(size * 0.5, 2), round(size * 0.5, 2) + for f in range(floors - 1): + world["elevators"].append({"id": f"elev-{f}", "x": shaft_x, "y": shaft_y, + "from": f, "to": f + 1}) + return world + + +def item_count(world: dict) -> int: + return sum(len(fl.get("items", [])) for fl in world.get("floors", [])) diff --git a/tests/test_worlds.py b/tests/test_worlds.py new file mode 100644 index 0000000..6278bcf --- /dev/null +++ b/tests/test_worlds.py @@ -0,0 +1,26 @@ +"""Multi-floor warehouse world for fleet sim (platform spec 04 P1).""" +from roborun import worlds + + +def test_warehouse_structure(): + w = worlds.warehouse(floors=3, rooms_per_floor=6, size=48, seed=1) + assert w["name"] == "warehouse" and len(w["floors"]) == 3 + for fl in w["floors"]: + assert len(fl["rooms"]) == 6 and fl["walls"] and fl["items"] + # elevators connect adjacent floors (n-1 of them), at a shared shaft + assert len(w["elevators"]) == 2 + assert {e["from"] for e in w["elevators"]} == {0, 1} + assert all(e["to"] == e["from"] + 1 for e in w["elevators"]) + assert worlds.item_count(w) == 3 * 6 * 2 + + +def test_warehouse_deterministic(): + a = worlds.warehouse(seed=42) + b = worlds.warehouse(seed=42) + assert a == b + assert worlds.warehouse(seed=1) != worlds.warehouse(seed=2) + + +def test_single_floor_has_no_elevators(): + w = worlds.warehouse(floors=1) + assert w["elevators"] == [] From 3e2873a79a5bddf03ea970174403e642279dcc31 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 14:45:43 -0400 Subject: [PATCH 096/126] fix stale test: isaac status is now 'available' (driver shipped) --- tests/test_projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_projects.py b/tests/test_projects.py index 68299ec..27470d6 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -90,7 +90,7 @@ def test_backend_registry(): ids = {b["id"] for b in bl} assert {"rapier", "mujoco", "mjx", "gazebo", "isaac", "real"} <= ids isaac = backends.get("isaac") - assert isaac["status"] == "planned" # honest: not built yet + assert isaac["status"] in {"available", "ready"} # driver exists (isaac.py) rapier = backends.get("rapier") assert rapier["status"] == "ready" and rapier["caps"]["fleet"] is True From 7d059c527bccab03c813cd2d0e92dee7aa160b68 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 14:49:20 -0400 Subject: [PATCH 097/126] =?UTF-8?q?Release=200.13.0=20=E2=80=94=20platform?= =?UTF-8?q?:=20Project/Environment/Run=20+=20all=209=20platform=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump for the platform feature set (projects/environments, data standard, telemetry browser, backends incl. Isaac, fleet sim with warehouse+elevators, spatial perception+tracks, unified recall, project/env data isolation, standard env + camera registration). Built + twine-checked; ready to publish. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 719a60b..794736e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ros-agent" -version = "0.12.0" +version = "0.13.0" description = "Quickly run and manage ROS 1/2 robots. Built-in MCP for Claude, Cursor, or any AI client, plus vision, sim, and hot-reload behaviors. Merkle proofs make every run tamper-evident and replayable." readme = "README.md" license = {text = "MIT"} From 77506aa8c920b242fec730a98f7d0417edf8d887 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 15:14:54 -0400 Subject: [PATCH 098/126] Dashboard overhaul: persistent app shell + Home + guided setup (Antioch-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform spine existed but the UX didn't reflect it. Now it's a real product: - shell.js + ui.css: ONE persistent chrome on every dashboard page — left sidebar (Home / Sims&Robots / Cockpit / Data{Browser,Scenarios,Timeline,Search,Analytics} / Fleet / Projects) + top bar with the PROJECT/ENVIRONMENT SWITCHER as the spine (switch → everything re-scopes) + live/go-live badge. Auto-wraps each page's content; pages dropped their bespoke navs. Retires project-chip.js. - home.html at /: the Antioch-style console — active scope hero, environments grid (open → launch), data KPIs, recent runs, quick actions. Clean go-live state on the static host. - setup.html at /setup: guided robot/sim setup — project → backend (live availability) → robot+task or warehouse fleet → Launch (creates env, sets active, routes to /sim?level= / /fleet-sim / connect). Replaces the tutorial-wall modal. - Cockpit demoted to a workspace at /sim (/ is the dashboard now); its start modal is a slim level-switcher with links back to Dashboard/Setup. build_site index = home.html; vercel.json rewrites /sim → arena.html so the playable demo still works. Data is divided by project/environment everywhere, not muddled. 277 passed, 1 skipped. --- roborun/server.py | 5 +- roborun/web/analytics.html | 2 +- roborun/web/arena.html | 22 ++--- roborun/web/browser.html | 1 + roborun/web/fleet-sim.html | 2 +- roborun/web/fleet.html | 2 +- roborun/web/home.html | 113 +++++++++++++++++++++++++ roborun/web/project-chip.js | 36 -------- roborun/web/projects.html | 1 + roborun/web/run.html | 2 +- roborun/web/scenarios.html | 2 +- roborun/web/search.html | 2 +- roborun/web/setup.html | 159 ++++++++++++++++++++++++++++++++++++ roborun/web/shell.js | 140 +++++++++++++++++++++++++++++++ roborun/web/timeline.html | 2 +- roborun/web/ui.css | 51 ++++++++++++ roborun/web/vercel.json | 5 +- scripts/build_site.py | 4 +- 18 files changed, 489 insertions(+), 62 deletions(-) create mode 100644 roborun/web/home.html delete mode 100644 roborun/web/project-chip.js create mode 100644 roborun/web/setup.html create mode 100644 roborun/web/shell.js diff --git a/roborun/server.py b/roborun/server.py index e5db8f2..99ac38d 100644 --- a/roborun/server.py +++ b/roborun/server.py @@ -116,8 +116,11 @@ def do_GET(self) -> None: self.send_header("Location", "/") self.end_headers() return + # the dashboard home is the entry; the immersive cockpit lives at /sim if path_only == "/": - self.path = "/arena.html" + self.path = "/home.html" + if path_only == "/setup": + self.path = "/setup.html" # the fleet comms sandbox is its own page; "/fleet" is the clean URL if path_only == "/fleet": self.path = "/fleet.html" diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html index 6ae5d02..9cefb89 100644 --- a/roborun/web/analytics.html +++ b/roborun/web/analytics.html @@ -6,7 +6,7 @@ RoboRun · Analytics - + + + +
+
+

Environments + new

+
loading…
+

Data overview

+
+

Recent runs browse all →

+
+
+ + + diff --git a/roborun/web/project-chip.js b/roborun/web/project-chip.js deleted file mode 100644 index aa22ac6..0000000 --- a/roborun/web/project-chip.js +++ /dev/null @@ -1,36 +0,0 @@ -/* project-chip.js — a live "where am I recording" indicator on every dashboard. - * - * The whole point of projects/environments is data isolation; if you can't SEE - * which project a page is scoped to, the feature is invisible. This injects a - * chip into the page nav showing the active project/environment (or "scratch"), - * linking to /projects to switch. Loaded by every dashboard; the cockpit has its - * own toolbar chip already. - */ -(function () { - function render(active) { - if (document.querySelector(".proj-chip")) return; // idempotent - var c = document.createElement("a"); - c.href = "/projects"; - c.className = "proj-chip" + (active ? " scoped" : ""); - c.title = "active project — recordings, scenarios and search scope here. click to switch."; - c.textContent = active ? ("◆ " + active.project + " / " + active.environment) - : "◇ scratch"; - var nav = document.querySelector("header nav, nav.nav, nav"); - if (nav) { - nav.insertBefore(c, nav.firstChild); - } else { - // no nav on this page (e.g. fleet) — pin it so scope is always visible - c.style.cssText = "position:fixed;top:12px;right:12px;z-index:9998;background:#0b0e0c"; - (document.body || document.documentElement).appendChild(c); - } - } - function go() { - fetch("/api/projects/active") - .then(function (r) { return r.json(); }) - .then(function (d) { render(d.active); }) - .catch(function () { render(null); }); - } - if (document.readyState === "loading") - document.addEventListener("DOMContentLoaded", go); - else go(); -})(); diff --git a/roborun/web/projects.html b/roborun/web/projects.html index 864648d..a9b4a13 100644 --- a/roborun/web/projects.html +++ b/roborun/web/projects.html @@ -6,6 +6,7 @@ RoboRun · Projects + + + +
+

Set up a robot or simulation. Pick where the data lives, the backend that drives it, and what to run — then launch. Everything you record scopes to this environment.

+ +
+

1Project — where the data lives

+
+ + or + +
+
+ +
+

2Backend — what drives it

+
loading…
+
+ +
+

3What to run

+
robot
+
+
task / world
+
+
+ + + +
+

4Environment

+
+ + +
+
+ +
+ + +
+
+ + + diff --git a/roborun/web/shell.js b/roborun/web/shell.js new file mode 100644 index 0000000..15a6d0b --- /dev/null +++ b/roborun/web/shell.js @@ -0,0 +1,140 @@ +/* shell.js — the persistent app shell (sidebar + top bar) for every dashboard + * page. The project/environment switcher in the top bar is the spine: it scopes + * all data (the server scopes via the active project). Pages just load this + * script; the shell removes their bespoke header/nav, wraps their
, and + * provides one consistent chrome. The cockpit (/sim) does NOT use the shell. + */ +(function () { + const PATH = location.pathname.replace(/\/$/, "") || "/"; + const NAV = [ + { items: [{ icon: "⌂", label: "Home", href: "/" }] }, + { group: "Build", items: [ + { icon: "▣", label: "Sims & Robots", href: "/setup" }, + { icon: "▦", label: "Cockpit", href: "/sim" }] }, + { group: "Data", items: [ + { icon: "⊞", label: "Data Browser", href: "/browser" }, + { icon: "✦", label: "Scenarios", href: "/scenarios" }, + { icon: "🕓", label: "Timeline", href: "/timeline" }, + { icon: "⌕", label: "Search", href: "/search" }, + { icon: "📊", label: "Analytics", href: "/analytics" }] }, + { group: "Fleet", items: [ + { icon: "🐝", label: "Fleet Sim", href: "/fleet-sim" }, + { icon: "◈", label: "Swarm Lab", href: "/fleet" }] }, + { items: [{ icon: "⚙", label: "Projects", href: "/projects" }] }, + ]; + const isActive = (href) => href === "/" ? PATH === "/" : + (PATH === href || PATH.startsWith(href + "/")); + + function sidebarHTML() { + return NAV.map(sec => + (sec.group ? `` : "") + + sec.items.map(it => + ` + ${it.icon}${it.label}`).join("") + ).join(""); + } + + function build() { + // drop any bespoke page header/nav — the shell owns navigation + document.querySelectorAll("body > header, body > nav").forEach(h => h.remove()); + // capture ALL remaining page content (main + any stray bars/divs), not just + //
, so nothing gets orphaned outside the shell + const content = [...document.body.children].filter(el => + el.tagName !== "SCRIPT" && el.id !== "runtime-badge"); + + const title = (document.title || "RoboRun").replace(/^RoboRun\s*[·|-]\s*/i, ""); + const shell = document.createElement("div"); + shell.className = "app-shell"; + shell.innerHTML = ` + +
+
+
${title}
+
+
+ + +
+ + New +
+
+
+
`; + document.body.insertBefore(shell, document.body.firstChild); + const host = shell.querySelector("#appMain"); + content.forEach(el => host.appendChild(el)); + + wireScope(); + wireLive(); + } + + // ── project / environment switcher ─────────────────────────────────────── + async function wireScope() { + const btn = document.getElementById("scopeBtn"); + const menu = document.getElementById("scopeMenu"); + async function refresh() { + try { + const a = await (await fetch("/api/projects/active")).json(); + btn.textContent = a.active ? `◆ ${a.active.project} / ${a.active.environment} ▾` : "◇ scratch ▾"; + btn.classList.toggle("scoped", !!a.active); + } catch { btn.textContent = "○ offline ▾"; } + } + async function openMenu() { + let html = ""; + try { + const d = await (await fetch("/api/projects")).json(); + const act = d.active; + if (!d.projects || !d.projects.length) { + html = `
No projects yet — everything is in scratch.
`; + } else { + for (const p of d.projects) { + const envs = await (await fetch("/api/environments?project=" + encodeURIComponent(p.id))).json(); + html += `
${p.name}
`; + html += (envs.environments || []).map(e => { + const on = act && act.project === p.id && act.environment === e.id; + return ` + ${e.name} ${e.backend}·${e.mode}`; + }).join(""); + } + } + } catch { html = `
○ run roborun to go live.
`; } + html += ``; + menu.innerHTML = html; + menu.style.display = "block"; + menu.querySelectorAll(".scope-env").forEach(a => a.onclick = async () => { + await fetch("/api/projects/active", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project: a.dataset.p, environment: a.dataset.e }) }); + location.reload(); + }); + const clr = document.getElementById("scopeClear"); + if (clr) clr.onclick = async () => { + await fetch("/api/projects/active/clear", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + location.reload(); + }; + } + btn.onclick = (e) => { e.stopPropagation(); if (menu.style.display === "none") openMenu(); else menu.style.display = "none"; }; + document.addEventListener("click", () => { menu.style.display = "none"; }); + menu.addEventListener("click", e => e.stopPropagation()); + refresh(); + } + + function wireLive() { + const el = document.getElementById("shellLive"); + const render = (s) => { + if (s && s.live) { el.innerHTML = '● live' + (s.remote ? " · :8765" : ""); } + else { el.innerHTML = '○ try the sim · run roborun'; } + }; + if (window.ROBORUN_RUNTIME) render(window.ROBORUN_RUNTIME); + document.addEventListener("roborun-runtime", e => render(e.detail)); + // fallback probe + fetch("/api/health").then(r => render({ live: r.ok })).catch(() => render({ live: false })); + } + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", build); + else build(); +})(); diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index 59ce386..fe5d54b 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -6,7 +6,7 @@ RoboRun · Timeline - + -
-

📊 ANALYTICS

- -
-

A live overview of everything your robots have seen and done — what they detected, how often, which robots are active, and how your behaviors score. Updates every few seconds.

+

A live overview of everything your robots have seen and done — what they detected, how often, which robots are active, and how your behaviors score. Updates every few seconds.

Detections by label (all time)

@@ -56,52 +68,83 @@

📊 ANALYTICS

-
-

DATA BROWSER

- Every recorded run in this project — sim, robot, fleet — with its streams. Open one to scrub the synced telemetry, camera, map and lidar. - -
-
+
+

DATA BROWSER

+

Every recorded run in the active scope — sim, robot, fleet — with its streams. + Open one to scrub the synced telemetry, camera, map and lidar.

+
-

Spatial map — what's where in this environment

-
loading…
+
+
+

Spatial map

+ +
+
loading…
+
-

Runs

-
loading…
+
+
+

Runs

+ +
+
loading…
+
-

Backends — what this install can run

-
loading…
+
+
+

Backends — what this install can run

+ +
+
loading…
+
-
-

🐝 FLEET SIM

- -
A swarm of robots covering one large warehouse, across floors, via elevators — collecting detections jointly into the active project's environment.
-
- - - - - + +
+
+

🐝 FLEET SIM

+ running +
+
+ +
+
+ robots 8 + +
+
+ floors 3 + +
+
+ speed 1.0× + +
+
+ + +
+
+
+ +
+ Legend + robot (color per unit) + detected item + undetected item + elevator + — walls / rooms +
+ +
+
loading warehouse…
-
diff --git a/roborun/web/fleet-sim.js b/roborun/web/fleet-sim.js index d8ca140..80ff15f 100644 --- a/roborun/web/fleet-sim.js +++ b/roborun/web/fleet-sim.js @@ -6,6 +6,7 @@ const $ = (s) => document.querySelector(s); const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", "#40c0c0", "#e090c0", "#90c040"]; + const FONT = "ui-monospace, Menlo, monospace"; const S = { world: null, robots: [], playing: true, speed: 1.0, n: 8, floors: 3, t0: performance.now(), detections: 0, detectedKeys: new Set(), canvases: [] }; @@ -42,7 +43,7 @@ const sp = S.world.spawns[i % S.world.spawns.length]; const rb = { id: "r" + i, x: sp.x + (Math.random() * 6 - 3), y: sp.y + (Math.random() * 6 - 3), h: Math.random() * 6.28, floor: 0, color: COLORS[i % COLORS.length], - inElev: 0, seen: 0, stuck: 0, target: null }; + inElev: 0, seen: 0, stuck: 0, target: null, trail: [] }; pickTarget(rb); S.robots.push(rb); } } @@ -86,6 +87,13 @@ // keep inside the building; re-target if pinned at a wall if (nx > 0.5 && nx < sz - 0.5) rb.x = nx; else rb.stuck += dt; if (ny > 0.5 && ny < sz - 0.5) rb.y = ny; else rb.stuck += dt; + // breadcrumb trail (capped) + const tr = rb.trail || (rb.trail = []); + const lp = tr[tr.length - 1]; + if (!lp || Math.hypot(rb.x - lp.x, rb.y - lp.y) > 1.4) { + tr.push({ x: rb.x, y: rb.y, f: rb.floor }); + if (tr.length > 18) tr.shift(); + } detect(rb); if ((rb.stuck += dt * 0.0) > 0 && rb.stuck > 2.5) pickTarget(rb); } @@ -93,10 +101,16 @@ function buildCanvases() { const host = $("#floors"); host.innerHTML = ""; S.canvases = []; + const dpr = Math.min(2, window.devicePixelRatio || 1); for (const fl of S.world.floors) { const div = document.createElement("div"); div.className = "floor"; - div.innerHTML = `

Floor ${fl.level}

`; - const cv = document.createElement("canvas"); cv.width = 520; cv.height = 520; + div.innerHTML = `

Floor ${fl.level}` + + `0 bots` + + `0/${fl.items.length} found

`; + const cv = document.createElement("canvas"); + const CSS = 520; cv.style.aspectRatio = "1 / 1"; + cv.width = CSS * dpr; cv.height = CSS * dpr; + const g = cv.getContext("2d"); g.scale(dpr, dpr); cv._cw = CSS; div.appendChild(cv); host.appendChild(div); S.canvases[fl.level] = cv; } } @@ -105,49 +119,106 @@ const sz = S.world.size; for (const fl of S.world.floors) { const cv = S.canvases[fl.level]; if (!cv) continue; - const g = cv.getContext("2d"); const W = cv.width, H = cv.height, P = 10; + const g = cv.getContext("2d"); const W = cv._cw, H = cv._cw, P = 14; const sc = (W - 2 * P) / sz; const X = x => P + x * sc, Y = y => P + y * sc; g.clearRect(0, 0, W, H); - // rooms - g.strokeStyle = "rgba(120,175,144,.10)"; g.lineWidth = 1; - for (const r of fl.rooms) { const rc = r.rect; g.strokeRect(X(rc[0]), Y(rc[1]), (rc[2] - rc[0]) * sc, (rc[3] - rc[1]) * sc); } - // walls - g.strokeStyle = "#2c3b2c"; g.lineWidth = 2; g.beginPath(); + + // building backdrop + g.fillStyle = "#0b110d"; + g.fillRect(X(0), Y(0), sz * sc, sz * sc); + + // room fills + soft outlines (alternating tint for legibility) + fl.rooms.forEach((r, i) => { + const rc = r.rect, rx = X(rc[0]), ry = Y(rc[1]), rw = (rc[2] - rc[0]) * sc, rh = (rc[3] - rc[1]) * sc; + g.fillStyle = (i % 2) ? "rgba(120,175,144,.030)" : "rgba(120,175,144,.060)"; + g.fillRect(rx, ry, rw, rh); + g.strokeStyle = "rgba(120,175,144,.16)"; g.lineWidth = 1; + g.strokeRect(rx + .5, ry + .5, rw - 1, rh - 1); + }); + + // exterior + interior walls (doorway gaps come from the data) + g.strokeStyle = "#3a5340"; g.lineWidth = 2.5; g.lineCap = "round"; + g.beginPath(); for (const w of fl.walls) { g.moveTo(X(w[0]), Y(w[1])); g.lineTo(X(w[2]), Y(w[3])); } g.stroke(); - // items (detected ones glow) + + // robot breadcrumb trails (under items/robots) + for (const rb of S.robots) { + const tr = rb.trail; if (!tr || tr.length < 2 || rb.floor !== fl.level) continue; + g.strokeStyle = rb.color + "33"; g.lineWidth = 2; g.lineCap = "round"; g.beginPath(); + let started = false; + for (const p of tr) { + if (p.f !== fl.level) { started = false; continue; } + if (!started) { g.moveTo(X(p.x), Y(p.y)); started = true; } else g.lineTo(X(p.x), Y(p.y)); + } + g.stroke(); + } + + // items: detected glow green, undetected muted; label detected on hover-scale + g.textAlign = "center"; g.textBaseline = "middle"; for (let k = 0; k < fl.items.length; k++) { const it = fl.items[k], on = S.detectedKeys.has(fl.level + ":" + k); - g.fillStyle = on ? "rgba(0,212,126,.9)" : "rgba(120,175,144,.35)"; - g.beginPath(); g.arc(X(it.x), Y(it.y), on ? 3.5 : 2.2, 0, 6.28); g.fill(); + const px = X(it.x), py = Y(it.y); + if (on) { + g.fillStyle = "rgba(0,212,126,.22)"; g.beginPath(); g.arc(px, py, 8, 0, 6.28); g.fill(); + g.fillStyle = "#00d47e"; g.beginPath(); g.arc(px, py, 3.6, 0, 6.28); g.fill(); + g.fillStyle = "rgba(166,188,173,.7)"; g.font = "8px " + FONT; + g.fillText(it.label, px, py - 11); + } else { + g.fillStyle = "rgba(120,175,144,.32)"; g.strokeStyle = "rgba(120,175,144,.5)"; + g.lineWidth = 1; g.beginPath(); g.arc(px, py, 2.6, 0, 6.28); g.fill(); g.stroke(); + } } - // elevators + + // elevators: clearly boxed + labeled ⇅ for (const e of elevatorsOn(fl.level)) { - g.strokeStyle = "#4090e0"; g.fillStyle = "rgba(64,144,224,.15)"; g.lineWidth = 1.5; - const s = 3.0 * sc; g.fillRect(X(e.x) - s / 2, Y(e.y) - s / 2, s, s); g.strokeRect(X(e.x) - s / 2, Y(e.y) - s / 2, s, s); - g.fillStyle = "#4090e0"; g.font = "9px monospace"; g.fillText("⇅", X(e.x) - 3, Y(e.y) + 3); + const s = Math.max(16, 3.0 * sc), ex = X(e.x), ey = Y(e.y); + g.fillStyle = "rgba(64,144,224,.18)"; g.strokeStyle = "#4090e0"; g.lineWidth = 1.5; + roundRect(g, ex - s / 2, ey - s / 2, s, s, 3); g.fill(); g.stroke(); + g.fillStyle = "#7fb6ec"; g.font = "bold 11px " + FONT; + g.fillText("⇅", ex, ey + .5); } - // robots on this floor + + // robots on this floor — larger, outlined triangles in their unit color let cnt = 0; for (const rb of S.robots) { if (rb.floor !== fl.level || rb.inElev > 0) continue; cnt++; - const x = X(rb.x), y = Y(rb.y); - g.fillStyle = rb.color; g.beginPath(); - g.moveTo(x + Math.cos(rb.h) * 6, y + Math.sin(rb.h) * 6); - g.lineTo(x + Math.cos(rb.h + 2.5) * 5, y + Math.sin(rb.h + 2.5) * 5); - g.lineTo(x + Math.cos(rb.h - 2.5) * 5, y + Math.sin(rb.h - 2.5) * 5); - g.closePath(); g.fill(); + const x = X(rb.x), y = Y(rb.y), R = 9; + g.beginPath(); + g.moveTo(x + Math.cos(rb.h) * R, y + Math.sin(rb.h) * R); + g.lineTo(x + Math.cos(rb.h + 2.5) * R * .8, y + Math.sin(rb.h + 2.5) * R * .8); + g.lineTo(x + Math.cos(rb.h - 2.5) * R * .8, y + Math.sin(rb.h - 2.5) * R * .8); + g.closePath(); + g.fillStyle = rb.color; g.fill(); + g.strokeStyle = "rgba(7,10,8,.9)"; g.lineWidth = 1.5; g.stroke(); } - const fc = document.getElementById("fc" + fl.level); if (fc) fc.textContent = cnt + " bots"; + + const fc = document.getElementById("fc" + fl.level); if (fc) fc.textContent = cnt; + let found = 0; + for (let k = 0; k < fl.items.length; k++) if (S.detectedKeys.has(fl.level + ":" + k)) found++; + const fi = document.getElementById("fi" + fl.level); if (fi) fi.textContent = found; } } + function roundRect(g, x, y, w, h, r) { + g.beginPath(); + g.moveTo(x + r, y); + g.arcTo(x + w, y, x + w, y + h, r); + g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); + g.arcTo(x, y, x + w, y, r); + g.closePath(); + } + function hud() { const cov = S.totalItems ? Math.round(S.detections / S.totalItems * 100) : 0; const el = (performance.now() - S.t0) / 1000; - const chip = (n, l) => `${n} ${l}`; - $("#kpis").innerHTML = chip(S.robots.length, "robots") + chip(S.detections + "/" + S.totalItems, "found") + - chip(cov + "%", "coverage") + chip(el.toFixed(0) + "s", "elapsed"); + const tile = (n, l) => `
${n}
${l}
`; + $("#kpis").innerHTML = + tile(S.robots.length, "robots") + + tile(S.detections + "/" + S.totalItems + "", "found") + + tile(cov + "%", "coverage") + + tile(el.toFixed(0) + "s", "elapsed"); } let last = performance.now(); @@ -166,7 +237,16 @@ $("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; spawn(); }; $("#fl").oninput = e => { S.floors = +e.target.value; $("#flab").textContent = S.floors; reset(); }; $("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; }; - $("#play").onclick = () => { S.playing = !S.playing; $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; }; + function setLive() { + const live = $("#live"), txt = $("#livetxt"); + if (live) live.classList.toggle("paused", !S.playing); + if (txt) txt.textContent = S.playing ? "running" : "paused"; + } + $("#play").onclick = () => { + S.playing = !S.playing; + $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; + setLive(); + }; $("#reset").onclick = reset; // show what env we're collecting into diff --git a/roborun/web/fleet.html b/roborun/web/fleet.html index 0cf6717..5fbb942 100644 --- a/roborun/web/fleet.html +++ b/roborun/web/fleet.html @@ -3,371 +3,421 @@ -RoboRun · Fleet +RoboRun · Swarm Lab - - -
-
◈◈◈FLEET LAB - — a sandbox for swarm coordination · for your real robots' status, see Analytics
-
- - - - ← Cockpit -
- -
-

The world

-
-
Environment
- -
where the swarm searches — obstacles block movement and break line-of-sight.
-
-
-
Robots6
- +
+
+
◈◈◈SWARM LAB + — a sandbox for swarm coordination · for your real robots' status, see Analytics
+
+ + +
-
-
Data points to find8
- -
things to discover and relay back to base — the green tower.
-
- -

What each robot can hear

-
-
Radio range14 m
- -
two robots talk only while this close. Past it, they're on their own.
-
-
-
Link reliability85%
- -
best-case delivery odds; messages fail more toward the edge of range.
-
+
+

Control panel

+
+

The world

+
+
Environment
+ +
where the swarm searches — obstacles block movement and break line-of-sight.
+
+
+
Robots6
+ +
+
+
Data points to find8
+ +
things to discover and relay back to base — the green tower.
+
+ -

What each robot can handle

-
-
Airtime4 / s
- -
messages a robot can send per second — its slice of the band.
-
-
-
Onboard memory60
- -
cells it remembers. Full memory forgets old ground — and may re-walk it.
-
-
-
Inbox depth8
- -
messages queued while it finishes its move. Full inbox → new messages are dropped.
-
+

What each robot can hear

+
+
Radio range14 m
+ +
two robots talk only while this close. Past it, they're on their own.
+
+
+
Link reliability85%
+ +
best-case delivery odds; messages fail more toward the edge of range.
+
-

Coordination strategy

-
-
+

What each robot can handle

+
+
Airtime4 / s
+ +
messages a robot can send per second — its slice of the band.
+
+
+
Onboard memory60
+ +
cells it remembers. Full memory forgets old ground — and may re-walk it.
+
+
+
Inbox depth8
+ +
messages queued while it finishes its move. Full inbox → new messages are dropped.
+
-
-
-
- - +
+
+

Live coordination

- + N robots · 1 lossy radio +
+
+ +
fleet booting…
-
-
-
-
Gossip
-
-
+
+
mapped once
+
overlap (wasted)
+
radio link
+
link at its limit
+
data → base
+
obstacle
+
+
+ + +
-
+
-
+
-
-
mapped once
-
overlap (wasted)
-
radio link
-
link at its limit
-
data → base
-
obstacle
-
- -
fleet booting…
- diff --git a/roborun/web/fleet.js b/roborun/web/fleet.js index 1dc93dc..6b197e4 100644 --- a/roborun/web/fleet.js +++ b/roborun/web/fleet.js @@ -462,24 +462,32 @@ function step(dt) { } /* ── view transform ──────────────────────────────────────────────────── */ +// The canvas fills #canvas-wrap inside the app shell (it is NOT the full +// viewport), so the view is sized from the canvas's own client box. The world +// square is centred within that box; ox/oy/s carry the device-pixel scale. const dpr = () => Math.min(2, devicePixelRatio || 1); let VIEW = { s: 1, ox: 0, oy: 0 }; function resize() { const k = dpr(); - cv.width = innerWidth * k; cv.height = innerHeight * k; - cv.style.width = innerWidth + "px"; cv.style.height = innerHeight + "px"; - const leftPad = innerWidth > 1000 ? 310 : 254, rightPad = innerWidth > 1000 ? 368 : 274; - const availW = Math.max(120, innerWidth - leftPad - rightPad), availH = innerHeight - 80 - 40; - const s = Math.max(5, Math.min(availW, availH) / WORLD); + const cw = cv.clientWidth || 120, ch = cv.clientHeight || 120; + cv.width = Math.round(cw * k); cv.height = Math.round(ch * k); + const pad = 18; // breathing room inside the stage + const availW = Math.max(60, cw - pad * 2), availH = Math.max(60, ch - pad * 2); + const s = Math.max(4, Math.min(availW, availH) / WORLD); VIEW.s = s * k; - VIEW.ox = (leftPad + (availW - WORLD * s) / 2) * k; - VIEW.oy = (80 + (availH - WORLD * s) / 2) * k; + VIEW.ox = (pad + (availW - WORLD * s) / 2) * k; + VIEW.oy = (pad + (availH - WORLD * s) / 2) * k; } addEventListener("resize", resize); +// keep the canvas matched to its container as the layout reflows +if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(cv); const wx = (x) => VIEW.ox + x * VIEW.s, wy = (y) => VIEW.oy + y * VIEW.s; +// pointer events arrive in viewport coords → subtract the canvas box origin const screenToWorld = (clientX, clientY) => { const k = dpr(); - return { x: (clientX * k - VIEW.ox) / VIEW.s, y: (clientY * k - VIEW.oy) / VIEW.s }; + const rect = cv.getBoundingClientRect(); + return { x: ((clientX - rect.left) * k - VIEW.ox) / VIEW.s, + y: ((clientY - rect.top) * k - VIEW.oy) / VIEW.s }; }; /* ── rendering ───────────────────────────────────────────────────────── */ @@ -619,7 +627,7 @@ function updateInspector() { let lx = hoverXY.x + 18, ly = hoverXY.y + 14; if (lx + w > innerWidth - pad) lx = hoverXY.x - w - 18; if (ly + 200 > innerHeight - pad) ly = innerHeight - 210; - el.style.left = Math.max(pad, lx) + "px"; el.style.top = Math.max(70, ly) + "px"; + el.style.left = Math.max(pad, lx) + "px"; el.style.top = Math.max(pad, ly) + "px"; } /* ── HUD ─────────────────────────────────────────────────────────────── */ diff --git a/roborun/web/home.html b/roborun/web/home.html index ae05d9e..b1a421e 100644 --- a/roborun/web/home.html +++ b/roborun/web/home.html @@ -8,74 +8,220 @@
-

Environments + new

-
loading…
-

Data overview

+
Environments+ new
+
loading environments…
+
Data overviewexplore →
-

Recent runs browse all →

-
+
Recent runsbrowse all →
+
-
-

PROJECTS

- A project owns its data and timeline and can span environments. Pick one and everything you record — cockpit, scenarios, search — scopes to it. scratch is the throwaway default so playing around never muddles real work. - -
+
+

Projects & environments

+

A project owns its data and timeline and can span environments. The active scope (set here or from the top bar) decides where everything you record — cockpit, scenarios, search — gets stored. scratch is the throwaway default so playing around never muddles real work.

+
+
-

New project

-
- - - - - +
+
+

New project

+ creates a project and switches you into it +
+
+
+
+ name + +
+
+ mode + +
+
+ backend + +
+
+   + +
+ +
+
-

Your projects

-
loading…
+
+
+

Your projects

+ +
+
loading…
+
-
-

RUN

- -
+
+ Run replay + +
+
loading…
-
-
-

🔍 SEARCH OVER TIME

- -
-
-
- - - -
- - - - - - - - -
-
Find any object or person across everything your robots have ever recorded — sim, real robots, and cameras. Type what you're looking for, or narrow it to a time range.
+
+
+
+ + + +
+
+ + +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
Find any object or person across everything your robots have ever recorded — sim, real robots, and cameras. Type what you're looking for, then narrow it with filters if you need a time range, a camera, or a combined recall.
-

Set up a robot or simulation. Pick where the data lives, the backend that drives it, and what to run — then launch. Everything you record scopes to this environment.

+

Set up a robot or simulation in four steps. Pick where the data lives, the backend that drives it, and what to run — then launch. Everything you record scopes to this environment.

-
-

1Project — where the data lives

+
+ 1 +

Projectwhere the data lives

- or + or
-
-

2Backend — what drives it

-
loading…
+
+ 2 +

Backendwhat drives it

+
loading backends…
-
-

3What to run

-
robot
+
+ 3 +

What to runrobot + task, or a fleet

+

Robot

-
task / world
-
+
+

Task / world

+
-
diff --git a/roborun/web/timeline.html b/roborun/web/timeline.html index fe5d54b..8b133ab 100644 --- a/roborun/web/timeline.html +++ b/roborun/web/timeline.html @@ -9,70 +9,98 @@ -
-

🕓 TIMELINE

- Pick a run to replay its timeline — every run is its own record. - -
-
-
-
- - - +
+
-
Select a run on the left to see its timeline — metadata, scores, flagged moments, and a link to the full synced replay.
-
+
Loading runs…
+ +
Select a run on the left to see its timeline — metadata, scores, flagged moments, and a link to the full synced replay.
+
From 1a6d736f0117ea3f2d7e863e12f1f70fe999a4c3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 16:36:31 -0400 Subject: [PATCH 103/126] =?UTF-8?q?Real=20data=20from=20real=20rapier=20?= =?UTF-8?q?=E2=80=94=20live=20sim=20detections=20become=20searchable;=20un?= =?UTF-8?q?ify=20store=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's point: dashboards should fill because you ran rapier, not from seeds. - /api/arena/state now indexes the sim's detections into the spatial store (throttled ~1 Hz, scoped to the active project/environment) — so just playing in rapier populates /search, /api/spatial (tracks) and /analytics with REAL, env-frame-positioned data. Previously detections only persisted if you started a recording and sealed it; the dashboards sat empty (which is why I'd seeded fakes). Verified: a sim detection is instantly searchable at its world pose. - Fixed a real bug: the searchable index used a CWD-relative '.roborun' while the recorder used '~/.roborun', so data split across two dirs. Unified the store to the same root as recorder.runs_root (honoring ROBORUN_STATE_DIR). - Wiped the 268MB of fake test observations + test runs → clean slate that only fills from real activity. --- roborun/routes/arena.py | 26 ++++++++++++++++++++++++++ roborun/spatial_memory.py | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/roborun/routes/arena.py b/roborun/routes/arena.py index 47fcec6..8550d79 100644 --- a/roborun/routes/arena.py +++ b/roborun/routes/arena.py @@ -19,6 +19,31 @@ def arena_cmd(h): _last_level: list[str] = [""] +_last_index: list[float] = [0.0] # throttle live store-indexing + + +def _index_live(payload: dict) -> None: + """Doing stuff in rapier should GENERATE real, searchable data — not seeds. + Index the sim's detections into the spatial store (scoped to the active + project/environment), throttled, so /search, /api/spatial and /analytics + fill from real play. Best-effort; never blocks the sim.""" + import time + dets = payload.get("detections") or [] + if not dets: + return + now = time.time() + if now - _last_index[0] < 1.0: # ~1 Hz is plenty for a searchable index + return + _last_index[0] = now + try: + from roborun.routes._singletons import get_memory + pose = payload.get("pose") or {} + robot = (payload.get("level") or {}).get("robot") or "sim" + get_memory().store(detections=dets, ts=now, + x=pose.get("x", 0.0), y=-pose.get("z", 0.0), + robot_id=f"{robot}-sim", source="sim", source_id="arena") + except Exception: + pass @post("/api/arena/state") @@ -32,6 +57,7 @@ def arena_state(h, payload): sightings.reset() sightings.observe(payload.get("detections") or [], pose=payload.get("pose"), source="arena") + _index_live(payload) # real rapier play → real searchable data # the black box gets the full ROS-shaped view: /pose, /detections, /lidar try: from roborun import recorder as rec_mod diff --git a/roborun/spatial_memory.py b/roborun/spatial_memory.py index 4f6c8e0..015acd7 100644 --- a/roborun/spatial_memory.py +++ b/roborun/spatial_memory.py @@ -50,8 +50,10 @@ def _default_db_path() -> Path: return dr / "spatial_memory.db" except Exception: pass + # Same root as the recorder (recorder.runs_root) so the searchable index and + # the MCAP runs live together — not split between CWD-relative and ~/.roborun. base = os.environ.get("ROBORUN_STATE_DIR") - return (Path(base) if base else Path(".roborun")) / "spatial_memory.db" + return (Path(base) if base else Path.home() / ".roborun") / "spatial_memory.db" DB_DIR = Path(".roborun") From 932839f70b1c95b32685a2578356f799658771db Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 16:45:54 -0400 Subject: [PATCH 104/126] =?UTF-8?q?Real=20Rapier=20fleet:=20N=20robots=20i?= =?UTF-8?q?n=20one=20physics=20warehouse=20=E2=86=92=20joint=20env=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 2D kinematic toy with an ACTUAL Rapier world (same engine as the cockpit): a warehouse of fixed-cuboid walls/rooms, N kinematic-capsule robots driven by a character controller so they collide with walls AND each other, navigating room to room. Top-down render of the true physics positions + trails. Every item a robot detects POSTs to the new /api/fleet/observe, which indexes it into the active project/environment store — so a fleet run fills /search and the spatial map with real, multi-robot, world-positioned data. Verified headlessly: Rapier WASM loads, robots physically move, coverage climbs, and the store grows from fleet detections. Old 2D fleet-sim.js removed. --- roborun/routes/arena.py | 20 +++ roborun/web/fleet-sim.html | 136 ++++++------------- roborun/web/fleet-sim.js | 258 ------------------------------------ roborun/web/rapier-fleet.js | 208 +++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 356 deletions(-) delete mode 100644 roborun/web/fleet-sim.js create mode 100644 roborun/web/rapier-fleet.js diff --git a/roborun/routes/arena.py b/roborun/routes/arena.py index 8550d79..163dacd 100644 --- a/roborun/routes/arena.py +++ b/roborun/routes/arena.py @@ -81,6 +81,26 @@ def arena_state(h, payload): send_json(h, 200, {"ok": True}) +@post("/api/fleet/observe") +def fleet_observe(h, payload): + """A fleet robot reports a detection at its world pose → indexed into the + active project/environment store (real multi-robot data, platform spec 04). + No throttle: each robot's first sighting of an item is one real observation.""" + dets = payload.get("detections") or [] + if dets: + try: + import time + from roborun.routes._singletons import get_memory + pose = payload.get("pose") or {} + get_memory().store(detections=dets, ts=time.time(), + x=float(pose.get("x", 0.0)), y=float(pose.get("y", 0.0)), + robot_id=str(payload.get("robot_id", "fleet")), + source="sim", source_id="fleet") + except Exception: + pass + send_json(h, 200, {"ok": True}) + + @post("/api/arena/event") def arena_event(h, payload): title = str(payload.get("title", "")).strip() diff --git a/roborun/web/fleet-sim.html b/roborun/web/fleet-sim.html index 7959067..89b8e35 100644 --- a/roborun/web/fleet-sim.html +++ b/roborun/web/fleet-sim.html @@ -7,117 +7,57 @@ RoboRun · Fleet Sim +
-
A swarm of robots covering one large warehouse, across floors, via elevators — collecting detections jointly into the active project's environment.
- -
-
-

🐝 FLEET SIM

- running -
- -
+
Real Rapier physics — N robots in one warehouse, colliding with walls and each other, collecting jointly into the active environment.
+
+
🐝FLEET SIM● Rapier physics
-
- robots 8 - -
-
- floors 3 - -
-
- speed 1.0× - -
-
- - -
+
+
+
+
-
- Legend - robot (color per unit) - detected item - undetected item - elevator - — walls / rooms +
+ robot (real physics body) + detected item + undetected item + — walls / rooms
-
-
loading warehouse…
-
+
- + diff --git a/roborun/web/fleet-sim.js b/roborun/web/fleet-sim.js deleted file mode 100644 index 80ff15f..0000000 --- a/roborun/web/fleet-sim.js +++ /dev/null @@ -1,258 +0,0 @@ -/* RoboRun · Fleet Sim (platform spec 04) — N robots in ONE large multi-floor - * warehouse, navigating across floors via elevators, collecting detections - * jointly into the active project's environment. 2D top-down, one shared clock. - */ -(() => { - const $ = (s) => document.querySelector(s); - const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", - "#40c0c0", "#e090c0", "#90c040"]; - const FONT = "ui-monospace, Menlo, monospace"; - const S = { world: null, robots: [], playing: true, speed: 1.0, - n: 8, floors: 3, t0: performance.now(), detections: 0, - detectedKeys: new Set(), canvases: [] }; - - async function fetchWorld() { - const d = await (await fetch(`/api/worlds/warehouse?floors=${S.floors}&rooms=6&seed=1`)).json(); - S.world = d.world; S.totalItems = d.items; - } - - function roomCenter(room) { - const r = room.rect; return { x: (r[0] + r[2]) / 2, y: (r[1] + r[3]) / 2 }; - } - function floorOf(level) { return S.world.floors[level]; } - function elevatorsOn(level) { return S.world.elevators.filter(e => e.from === level || e.to === level); } - - function pickTarget(rb) { - const fl = floorOf(rb.floor); - // ~25% of the time, head to an elevator to change floors - const elevs = elevatorsOn(rb.floor); - if (elevs.length && Math.random() < 0.25) { - const e = elevs[Math.floor(Math.random() * elevs.length)]; - rb.target = { x: e.x, y: e.y, elevator: e }; - } else { - const room = fl.rooms[Math.floor(Math.random() * fl.rooms.length)]; - const c = roomCenter(room); - rb.target = { x: c.x + (Math.random() - .5) * 4, y: c.y + (Math.random() - .5) * 4 }; - } - rb.stuck = 0; - } - - function spawn() { - S.robots = []; S.detections = 0; S.detectedKeys = new Set(); S.t0 = performance.now(); - for (let i = 0; i < S.n; i++) { - const sp = S.world.spawns[i % S.world.spawns.length]; - const rb = { id: "r" + i, x: sp.x + (Math.random() * 6 - 3), y: sp.y + (Math.random() * 6 - 3), - h: Math.random() * 6.28, floor: 0, color: COLORS[i % COLORS.length], - inElev: 0, seen: 0, stuck: 0, target: null, trail: [] }; - pickTarget(rb); S.robots.push(rb); - } - } - - function detect(rb) { - const items = floorOf(rb.floor).items; - for (let k = 0; k < items.length; k++) { - const it = items[k]; - if (Math.hypot(it.x - rb.x, it.y - rb.y) < 3.0) { - const key = rb.floor + ":" + k; - if (!S.detectedKeys.has(key)) { S.detectedKeys.add(key); S.detections++; } - rb.seen++; - } - } - } - - function step(dt) { - const v = 6.0; // m/s nominal - for (const rb of S.robots) { - if (rb.inElev > 0) { // riding the elevator between floors - rb.inElev -= dt; - if (rb.inElev <= 0 && rb.pendingFloor != null) { - rb.floor = rb.pendingFloor; rb.pendingFloor = null; pickTarget(rb); - } - continue; - } - const dx = rb.target.x - rb.x, dy = rb.target.y - rb.y; - const dist = Math.hypot(dx, dy); - if (dist < 1.2) { - if (rb.target.elevator) { // step into the shaft → other floor - const e = rb.target.elevator; - rb.pendingFloor = (e.from === rb.floor) ? e.to : e.from; - rb.inElev = 0.7; rb.x = e.x; rb.y = e.y; - } else { detect(rb); pickTarget(rb); } - continue; - } - rb.h = Math.atan2(dy, dx); - const nx = rb.x + Math.cos(rb.h) * v * dt; - const ny = rb.y + Math.sin(rb.h) * v * dt; - const sz = S.world.size; - // keep inside the building; re-target if pinned at a wall - if (nx > 0.5 && nx < sz - 0.5) rb.x = nx; else rb.stuck += dt; - if (ny > 0.5 && ny < sz - 0.5) rb.y = ny; else rb.stuck += dt; - // breadcrumb trail (capped) - const tr = rb.trail || (rb.trail = []); - const lp = tr[tr.length - 1]; - if (!lp || Math.hypot(rb.x - lp.x, rb.y - lp.y) > 1.4) { - tr.push({ x: rb.x, y: rb.y, f: rb.floor }); - if (tr.length > 18) tr.shift(); - } - detect(rb); - if ((rb.stuck += dt * 0.0) > 0 && rb.stuck > 2.5) pickTarget(rb); - } - } - - function buildCanvases() { - const host = $("#floors"); host.innerHTML = ""; S.canvases = []; - const dpr = Math.min(2, window.devicePixelRatio || 1); - for (const fl of S.world.floors) { - const div = document.createElement("div"); div.className = "floor"; - div.innerHTML = `

Floor ${fl.level}` + - `0 bots` + - `0/${fl.items.length} found

`; - const cv = document.createElement("canvas"); - const CSS = 520; cv.style.aspectRatio = "1 / 1"; - cv.width = CSS * dpr; cv.height = CSS * dpr; - const g = cv.getContext("2d"); g.scale(dpr, dpr); cv._cw = CSS; - div.appendChild(cv); host.appendChild(div); S.canvases[fl.level] = cv; - } - } - - function draw() { - const sz = S.world.size; - for (const fl of S.world.floors) { - const cv = S.canvases[fl.level]; if (!cv) continue; - const g = cv.getContext("2d"); const W = cv._cw, H = cv._cw, P = 14; - const sc = (W - 2 * P) / sz; const X = x => P + x * sc, Y = y => P + y * sc; - g.clearRect(0, 0, W, H); - - // building backdrop - g.fillStyle = "#0b110d"; - g.fillRect(X(0), Y(0), sz * sc, sz * sc); - - // room fills + soft outlines (alternating tint for legibility) - fl.rooms.forEach((r, i) => { - const rc = r.rect, rx = X(rc[0]), ry = Y(rc[1]), rw = (rc[2] - rc[0]) * sc, rh = (rc[3] - rc[1]) * sc; - g.fillStyle = (i % 2) ? "rgba(120,175,144,.030)" : "rgba(120,175,144,.060)"; - g.fillRect(rx, ry, rw, rh); - g.strokeStyle = "rgba(120,175,144,.16)"; g.lineWidth = 1; - g.strokeRect(rx + .5, ry + .5, rw - 1, rh - 1); - }); - - // exterior + interior walls (doorway gaps come from the data) - g.strokeStyle = "#3a5340"; g.lineWidth = 2.5; g.lineCap = "round"; - g.beginPath(); - for (const w of fl.walls) { g.moveTo(X(w[0]), Y(w[1])); g.lineTo(X(w[2]), Y(w[3])); } - g.stroke(); - - // robot breadcrumb trails (under items/robots) - for (const rb of S.robots) { - const tr = rb.trail; if (!tr || tr.length < 2 || rb.floor !== fl.level) continue; - g.strokeStyle = rb.color + "33"; g.lineWidth = 2; g.lineCap = "round"; g.beginPath(); - let started = false; - for (const p of tr) { - if (p.f !== fl.level) { started = false; continue; } - if (!started) { g.moveTo(X(p.x), Y(p.y)); started = true; } else g.lineTo(X(p.x), Y(p.y)); - } - g.stroke(); - } - - // items: detected glow green, undetected muted; label detected on hover-scale - g.textAlign = "center"; g.textBaseline = "middle"; - for (let k = 0; k < fl.items.length; k++) { - const it = fl.items[k], on = S.detectedKeys.has(fl.level + ":" + k); - const px = X(it.x), py = Y(it.y); - if (on) { - g.fillStyle = "rgba(0,212,126,.22)"; g.beginPath(); g.arc(px, py, 8, 0, 6.28); g.fill(); - g.fillStyle = "#00d47e"; g.beginPath(); g.arc(px, py, 3.6, 0, 6.28); g.fill(); - g.fillStyle = "rgba(166,188,173,.7)"; g.font = "8px " + FONT; - g.fillText(it.label, px, py - 11); - } else { - g.fillStyle = "rgba(120,175,144,.32)"; g.strokeStyle = "rgba(120,175,144,.5)"; - g.lineWidth = 1; g.beginPath(); g.arc(px, py, 2.6, 0, 6.28); g.fill(); g.stroke(); - } - } - - // elevators: clearly boxed + labeled ⇅ - for (const e of elevatorsOn(fl.level)) { - const s = Math.max(16, 3.0 * sc), ex = X(e.x), ey = Y(e.y); - g.fillStyle = "rgba(64,144,224,.18)"; g.strokeStyle = "#4090e0"; g.lineWidth = 1.5; - roundRect(g, ex - s / 2, ey - s / 2, s, s, 3); g.fill(); g.stroke(); - g.fillStyle = "#7fb6ec"; g.font = "bold 11px " + FONT; - g.fillText("⇅", ex, ey + .5); - } - - // robots on this floor — larger, outlined triangles in their unit color - let cnt = 0; - for (const rb of S.robots) { - if (rb.floor !== fl.level || rb.inElev > 0) continue; cnt++; - const x = X(rb.x), y = Y(rb.y), R = 9; - g.beginPath(); - g.moveTo(x + Math.cos(rb.h) * R, y + Math.sin(rb.h) * R); - g.lineTo(x + Math.cos(rb.h + 2.5) * R * .8, y + Math.sin(rb.h + 2.5) * R * .8); - g.lineTo(x + Math.cos(rb.h - 2.5) * R * .8, y + Math.sin(rb.h - 2.5) * R * .8); - g.closePath(); - g.fillStyle = rb.color; g.fill(); - g.strokeStyle = "rgba(7,10,8,.9)"; g.lineWidth = 1.5; g.stroke(); - } - - const fc = document.getElementById("fc" + fl.level); if (fc) fc.textContent = cnt; - let found = 0; - for (let k = 0; k < fl.items.length; k++) if (S.detectedKeys.has(fl.level + ":" + k)) found++; - const fi = document.getElementById("fi" + fl.level); if (fi) fi.textContent = found; - } - } - - function roundRect(g, x, y, w, h, r) { - g.beginPath(); - g.moveTo(x + r, y); - g.arcTo(x + w, y, x + w, y + h, r); - g.arcTo(x + w, y + h, x, y + h, r); - g.arcTo(x, y + h, x, y, r); - g.arcTo(x, y, x + w, y, r); - g.closePath(); - } - - function hud() { - const cov = S.totalItems ? Math.round(S.detections / S.totalItems * 100) : 0; - const el = (performance.now() - S.t0) / 1000; - const tile = (n, l) => `
${n}
${l}
`; - $("#kpis").innerHTML = - tile(S.robots.length, "robots") + - tile(S.detections + "/" + S.totalItems + "", "found") + - tile(cov + "%", "coverage") + - tile(el.toFixed(0) + "s", "elapsed"); - } - - let last = performance.now(); - function loop(now) { - const dt = Math.min(0.05, (now - last) / 1000) * S.speed; last = now; - if (S.playing && S.world) { step(dt); } - if (S.world) { draw(); hud(); } - requestAnimationFrame(loop); - } - - async function reset() { - await fetchWorld(); buildCanvases(); spawn(); - } - - // controls - $("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; spawn(); }; - $("#fl").oninput = e => { S.floors = +e.target.value; $("#flab").textContent = S.floors; reset(); }; - $("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; }; - function setLive() { - const live = $("#live"), txt = $("#livetxt"); - if (live) live.classList.toggle("paused", !S.playing); - if (txt) txt.textContent = S.playing ? "running" : "paused"; - } - $("#play").onclick = () => { - S.playing = !S.playing; - $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; - setLive(); - }; - $("#reset").onclick = reset; - - // show what env we're collecting into - fetch("/api/projects/active").then(r => r.json()).then(d => { - if (d.active) $("#scope").innerHTML = `Collecting jointly into ${d.active.project} / ${d.active.environment} — ${S.n} robots, one warehouse, ${S.floors} floors connected by elevators. Each robot's detections land in the same environment.`; - }).catch(() => {}); - - reset().then(() => requestAnimationFrame(loop)); -})(); diff --git a/roborun/web/rapier-fleet.js b/roborun/web/rapier-fleet.js new file mode 100644 index 0000000..af8673e --- /dev/null +++ b/roborun/web/rapier-fleet.js @@ -0,0 +1,208 @@ +/* rapier-fleet.js — a REAL fleet: N robots in one Rapier physics warehouse. + * + * Not the old 2D kinematic toy. This builds an actual Rapier world (same engine + * the cockpit uses), spawns N kinematic-capsule robots with a character + * controller so they collide with walls AND each other, navigates them room to + * room, and reports every detection to /api/fleet/observe — so a fleet run fills + * the active project/environment's search + spatial map with real, multi-robot + * data. Top-down render of the true physics positions. + */ +import RAPIER from "@dimforge/rapier3d-compat"; +import { initPhysics } from "./physics.js"; + +const $ = (s) => document.querySelector(s); +const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", "#40c0c0", "#e090c0", "#90c040"]; +const LABELS = ["pallet", "forklift", "shelf", "crate", "barrel", "agv", "worker", "bin"]; +const H = 1 / 60; + +const S = { world: null, ctl: null, robots: [], items: [], walls: [], rooms: [], + size: 50, playing: true, speed: 1, n: 8, found: 0, detected: new Set(), + t0: performance.now(), cv: null, ready: false }; + +// ── warehouse layout (deterministic): rooms grid + aisle walls w/ doorways ── +function buildLayout() { + const sz = S.size, cols = 4, rows = 3, cw = sz / cols, ch = sz / rows; + const walls = [], items = [], rooms = []; + walls.push([0, 0, sz, 0], [sz, 0, sz, sz], [sz, sz, 0, sz], [0, sz, 0, 0]); // perimeter + // interior partitions, split to leave doorway gaps at each room boundary + for (let c = 1; c < cols; c++) { + const x = c * cw; + for (let r = 0; r < rows; r++) walls.push([x, r * ch + 2.2, x, (r + 1) * ch - 2.2]); + } + for (let r = 1; r < rows; r++) { + const z = r * ch; + for (let c = 0; c < cols; c++) walls.push([c * cw + 2.2, z, (c + 1) * cw - 2.2, z]); + } + let seed = 7; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; + for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { + rooms.push({ x: c * cw, z: r * ch, w: cw, h: ch }); + const n = 1 + (rng() < 0.6 ? 1 : 0); + for (let k = 0; k < n; k++) + items.push({ label: LABELS[Math.floor(rng() * LABELS.length)], + x: c * cw + 2 + rng() * (cw - 4), z: r * ch + 2 + rng() * (ch - 4) }); + } + S.walls = walls; S.items = items; S.rooms = rooms; +} + +function roomCenter(rm) { return { x: rm.x + rm.w / 2, z: rm.z + rm.h / 2 }; } +function pickTarget(rb) { + const rm = S.rooms[Math.floor(Math.random() * S.rooms.length)]; + const c = roomCenter(rm); + rb.target = { x: c.x + (Math.random() - .5) * (rm.w - 6), z: c.z + (Math.random() - .5) * (rm.h - 6) }; +} + +// ── build the Rapier world ───────────────────────────────────────────────── +async function build() { + await initPhysics(); + buildLayout(); + const w = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); + w.timestep = H; + // ground + const g = w.createRigidBody(RAPIER.RigidBodyDesc.fixed()); + w.createCollider(RAPIER.ColliderDesc.cuboid(S.size, 0.1, S.size) + .setTranslation(S.size / 2, -0.1, S.size / 2).setFriction(0.8), g); + // walls (fixed cuboids) + for (const [x1, z1, x2, z2] of S.walls) { + const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2; + const hx = Math.max(Math.abs(x2 - x1) / 2, 0.12), hz = Math.max(Math.abs(z2 - z1) / 2, 0.12); + const b = w.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(cx, 0.6, cz)); + w.createCollider(RAPIER.ColliderDesc.cuboid(hx, 0.6, hz), b); + } + S.world = w; + S.ctl = w.createCharacterController(0.02); + S.ctl.setApplyImpulsesToDynamicBodies(false); + spawnRobots(); + S.ready = true; +} + +function spawnRobots() { + // remove old robot bodies + for (const rb of S.robots) { try { S.world.removeRigidBody(rb.body); } catch {} } + S.robots = []; S.found = 0; S.detected = new Set(); S.t0 = performance.now(); + const per = Math.ceil(Math.sqrt(S.n)); + for (let i = 0; i < S.n; i++) { + const gx = (i % per), gz = Math.floor(i / per); + const x = 3 + gx * 1.4 + Math.random() * .4, z = 3 + gz * 1.4 + Math.random() * .4; + const body = S.world.createRigidBody( + RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(x, 0.3, z)); + const col = S.world.createCollider(RAPIER.ColliderDesc.capsule(0.14, 0.3), body); + const rb = { id: "r" + i, body, col, color: COLORS[i % COLORS.length], + heading: Math.random() * 6.28, seen: 0, trail: [], target: null }; + pickTarget(rb); S.robots.push(rb); + } +} + +function detect(rb) { + const cur = rb.body.translation(); + for (let k = 0; k < S.items.length; k++) { + const it = S.items[k]; + if (Math.hypot(it.x - cur.x, it.z - cur.z) < 2.2) { + if (!S.detected.has(k)) { S.detected.add(k); S.found++; report(rb, it); } + rb.seen++; + } + } +} +function report(rb, it) { + // real fleet data → the active project/environment store + const p = rb.body.translation(); + fetch("/api/fleet/observe", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ robot_id: rb.id, pose: { x: p.x, y: p.z }, + detections: [{ label: it.label, score: 0.9, bbox: [0, 0, 12, 12] }] }) }).catch(() => {}); +} + +function stepOnce() { + const v = 1.6; + for (const rb of S.robots) { + const cur = rb.body.translation(); + let dx = rb.target.x - cur.x, dz = rb.target.z - cur.z; + const d = Math.hypot(dx, dz); + if (d < 1.0) { detect(rb); pickTarget(rb); continue; } + dx /= d; dz /= d; rb.heading = Math.atan2(dz, dx); + S.ctl.computeColliderMovement(rb.col, { x: dx * v * H, y: 0, z: dz * v * H }); + const mv = S.ctl.computedMovement(); + rb.body.setNextKinematicTranslation({ x: cur.x + mv.x, y: cur.y, z: cur.z + mv.z }); + const lp = rb.trail[rb.trail.length - 1]; + if (!lp || Math.hypot(cur.x - lp.x, cur.z - lp.z) > 1.2) { + rb.trail.push({ x: cur.x, z: cur.z }); if (rb.trail.length > 16) rb.trail.shift(); + } + detect(rb); + } + S.world.step(); +} + +// ── render: top-down view of the true physics positions ──────────────────── +function draw() { + const cv = S.cv; if (!cv) return; + const box = cv.clientWidth || 600; const dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(box * dpr)) { cv.width = Math.round(box * dpr); cv.height = Math.round(box * dpr); } + const g = cv.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0); + const W = box, P = 14, sc = (W - 2 * P) / S.size; + const X = x => P + x * sc, Y = z => P + z * sc; + g.clearRect(0, 0, W, W); + g.fillStyle = "#080b08"; g.fillRect(0, 0, W, W); + // rooms (faint) + g.strokeStyle = "rgba(120,175,144,.08)"; g.lineWidth = 1; + for (const rm of S.rooms) g.strokeRect(X(rm.x), Y(rm.z), rm.w * sc, rm.h * sc); + // walls + g.strokeStyle = "#2c3b2c"; g.lineWidth = 3; g.lineCap = "round"; g.beginPath(); + for (const [x1, z1, x2, z2] of S.walls) { g.moveTo(X(x1), Y(z1)); g.lineTo(X(x2), Y(z2)); } + g.stroke(); + // items + for (let k = 0; k < S.items.length; k++) { + const it = S.items[k], on = S.detected.has(k); + g.fillStyle = on ? "rgba(0,212,126,.95)" : "rgba(120,175,144,.32)"; + g.beginPath(); g.arc(X(it.x), Y(it.z), on ? 4 : 2.4, 0, 6.28); g.fill(); + if (on) { g.fillStyle = "rgba(0,212,126,.7)"; g.font = "9px ui-monospace,monospace"; g.fillText(it.label, X(it.x) + 6, Y(it.z) + 3); } + } + // robots (real Rapier positions) + trails + for (const rb of S.robots) { + if (rb.trail.length > 1) { + g.strokeStyle = rb.color + "33"; g.lineWidth = 1.5; g.beginPath(); + rb.trail.forEach((t, i) => i ? g.lineTo(X(t.x), Y(t.z)) : g.moveTo(X(t.x), Y(t.z))); g.stroke(); + } + const t = rb.body.translation(); const x = X(t.x), y = Y(t.z), h = rb.heading; + g.fillStyle = rb.color; g.beginPath(); + g.moveTo(x + Math.cos(h) * 7, y + Math.sin(h) * 7); + g.lineTo(x + Math.cos(h + 2.5) * 5.5, y + Math.sin(h + 2.5) * 5.5); + g.lineTo(x + Math.cos(h - 2.5) * 5.5, y + Math.sin(h - 2.5) * 5.5); + g.closePath(); g.fill(); + } +} + +function hud() { + const cov = S.items.length ? Math.round(S.found / S.items.length * 100) : 0; + const el = (performance.now() - S.t0) / 1000; + const tile = (n, l) => `
${n}
${l}
`; + $("#kpis").innerHTML = tile(S.robots.length, "robots") + tile(S.found + "/" + S.items.length, "found") + + tile(cov + "%", "coverage") + tile(el.toFixed(0) + "s", "elapsed"); +} + +let last = performance.now(), acc = 0; +function loop(now) { + if (S.ready) { + let dt = Math.min(0.05, (now - last) / 1000) * S.speed; last = now; + if (S.playing) { acc += dt; let guard = 0; while (acc >= H && guard++ < 240) { stepOnce(); acc -= H; } } + draw(); hud(); + } else { last = now; } + requestAnimationFrame(loop); +} + +// ── controls + boot ──────────────────────────────────────────────────────── +function wire() { + S.cv = $("#stage"); + $("#n") && ($("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; if (S.ready) spawnRobots(); }); + $("#spd") && ($("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; }); + $("#play") && ($("#play").onclick = () => { S.playing = !S.playing; $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; }); + $("#reset") && ($("#reset").onclick = () => { if (S.ready) spawnRobots(); }); + fetch("/api/projects/active").then(r => r.json()).then(d => { + const s = $("#scope"); if (!s) return; + s.innerHTML = d.active + ? `Real Rapier physics — ${S.n} robots collecting jointly into ${d.active.project} / ${d.active.environment}. Every detection lands in that environment's search + spatial map.` + : `Real Rapier physics — ${S.n} robots in one warehouse. Detections go to the active project (currently scratch). Pick a project to scope them.`; + }).catch(() => {}); +} + +wire(); +build().then(() => requestAnimationFrame(loop)).catch(e => { + const s = $("#scope"); if (s) s.innerHTML = `Rapier failed to load: ${e}`; +}); From c7b6a59c0f784a76ccf490eff4144bfdbeb306b1 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 16:50:38 -0400 Subject: [PATCH 105/126] Rapier fleet: multi-floor warehouse + elevators (real physics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked floors in one Rapier world (6m apart in Y, so physically independent), each a ground slab + walls. N robots start on floor 0; ~25% of targets are the central elevator shaft — reaching it transitions a robot to the adjacent floor (brief ride, then teleport to the shaft on the destination floor). One top-down canvas per floor renders the true physics positions + per-floor bot counts. Detections across all floors POST to /api/fleet/observe into the active env. Verified headlessly: 3 floor canvases, robots ride the elevator (8/0/0 → 5/3/0 across floors), coverage climbs, store fills. floors/robots/speed controls live. --- roborun/web/fleet-sim.html | 12 +- roborun/web/rapier-fleet.js | 241 +++++++++++++++++++++--------------- 2 files changed, 149 insertions(+), 104 deletions(-) diff --git a/roborun/web/fleet-sim.html b/roborun/web/fleet-sim.html index 89b8e35..9b182a0 100644 --- a/roborun/web/fleet-sim.html +++ b/roborun/web/fleet-sim.html @@ -31,8 +31,12 @@ .legend .sw{ display:inline-flex; align-items:center; gap:6px; } .legend .tri{ width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:9px solid var(--accent); } .legend .dot{ width:9px;height:9px;border-radius:50%; } .legend .dot.on{ background:var(--accent); } .legend .dot.off{ background:#5a7a6a; } - .stagewrap{ display:flex; justify-content:center; } - #stage{ width:min(640px,100%); aspect-ratio:1; display:block; background:#080b08; border:1px solid var(--line); border-radius:var(--r); } + .legend .elev{ width:11px;height:11px;border:1.5px solid var(--blue);border-radius:2px;display:inline-flex;align-items:center;justify-content:center;color:var(--blue);font-size:8px; } + #floors{ display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:14px; } + .floorp{ background:var(--panel); border:1px solid var(--line); border-radius:var(--r); padding:10px; } + .floorp h3{ font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--fg-dim); margin:0 0 8px; display:flex; justify-content:space-between; } + .floorp h3 b{ color:var(--accent); font-weight:600; } + .floorp canvas{ width:100%; aspect-ratio:1; display:block; background:#080b08; border-radius:6px; } @@ -43,6 +47,7 @@
🐝FLEET SIM● Rapier physics
+
@@ -53,10 +58,11 @@ robot (real physics body) detected item undetected item + elevator (between floors) — walls / rooms -
+
diff --git a/roborun/web/rapier-fleet.js b/roborun/web/rapier-fleet.js index af8673e..336f90f 100644 --- a/roborun/web/rapier-fleet.js +++ b/roborun/web/rapier-fleet.js @@ -1,11 +1,12 @@ -/* rapier-fleet.js — a REAL fleet: N robots in one Rapier physics warehouse. +/* rapier-fleet.js — a REAL multi-floor fleet: N robots in one Rapier physics + * warehouse, stacked floors connected by elevators. * - * Not the old 2D kinematic toy. This builds an actual Rapier world (same engine - * the cockpit uses), spawns N kinematic-capsule robots with a character - * controller so they collide with walls AND each other, navigates them room to - * room, and reports every detection to /api/fleet/observe — so a fleet run fills - * the active project/environment's search + spatial map with real, multi-robot - * data. Top-down render of the true physics positions. + * One Rapier world; each floor is a ground slab + walls at its own Y level (6 m + * apart, so floors are physically independent). N kinematic-capsule robots with + * a character controller collide with walls AND each other, navigate room to + * room, and ride elevators between floors. Every detection POSTs to + * /api/fleet/observe → indexed into the active project/environment store. One + * top-down canvas per floor renders the true physics positions. */ import RAPIER from "@dimforge/rapier3d-compat"; import { initPhysics } from "./physics.js"; @@ -13,97 +14,105 @@ import { initPhysics } from "./physics.js"; const $ = (s) => document.querySelector(s); const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", "#40c0c0", "#e090c0", "#90c040"]; const LABELS = ["pallet", "forklift", "shelf", "crate", "barrel", "agv", "worker", "bin"]; -const H = 1 / 60; +const H = 1 / 60, FLOOR_GAP = 6; +const floorY = (f) => f * FLOOR_GAP; -const S = { world: null, ctl: null, robots: [], items: [], walls: [], rooms: [], - size: 50, playing: true, speed: 1, n: 8, found: 0, detected: new Set(), - t0: performance.now(), cv: null, ready: false }; +const S = { world: null, ctl: null, robots: [], floors: 3, size: 50, playing: true, + speed: 1, n: 8, floorData: [], elevators: [], detected: new Set(), found: 0, + totalItems: 0, t0: performance.now(), canvases: [], ready: false }; -// ── warehouse layout (deterministic): rooms grid + aisle walls w/ doorways ── +// ── deterministic warehouse layout per floor + elevators ─────────────────── function buildLayout() { const sz = S.size, cols = 4, rows = 3, cw = sz / cols, ch = sz / rows; - const walls = [], items = [], rooms = []; - walls.push([0, 0, sz, 0], [sz, 0, sz, sz], [sz, sz, 0, sz], [0, sz, 0, 0]); // perimeter - // interior partitions, split to leave doorway gaps at each room boundary - for (let c = 1; c < cols; c++) { - const x = c * cw; - for (let r = 0; r < rows; r++) walls.push([x, r * ch + 2.2, x, (r + 1) * ch - 2.2]); - } - for (let r = 1; r < rows; r++) { - const z = r * ch; - for (let c = 0; c < cols; c++) walls.push([c * cw + 2.2, z, (c + 1) * cw - 2.2, z]); - } + S.floorData = []; S.elevators = []; S.totalItems = 0; let seed = 7; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; - for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { - rooms.push({ x: c * cw, z: r * ch, w: cw, h: ch }); - const n = 1 + (rng() < 0.6 ? 1 : 0); - for (let k = 0; k < n; k++) - items.push({ label: LABELS[Math.floor(rng() * LABELS.length)], - x: c * cw + 2 + rng() * (cw - 4), z: r * ch + 2 + rng() * (ch - 4) }); + for (let f = 0; f < S.floors; f++) { + const walls = [], items = [], rooms = []; + walls.push([0, 0, sz, 0], [sz, 0, sz, sz], [sz, sz, 0, sz], [0, sz, 0, 0]); + for (let c = 1; c < cols; c++) { const x = c * cw; for (let r = 0; r < rows; r++) walls.push([x, r * ch + 2.2, x, (r + 1) * ch - 2.2]); } + for (let r = 1; r < rows; r++) { const z = r * ch; for (let c = 0; c < cols; c++) walls.push([c * cw + 2.2, z, (c + 1) * cw - 2.2, z]); } + for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { + rooms.push({ x: c * cw, z: r * ch, w: cw, h: ch }); + const n = 1 + (rng() < 0.6 ? 1 : 0); + for (let k = 0; k < n; k++) items.push({ label: LABELS[Math.floor(rng() * LABELS.length)], + x: c * cw + 2 + rng() * (cw - 4), z: r * ch + 2 + rng() * (ch - 4) }); + } + S.floorData.push({ walls, items, rooms }); S.totalItems += items.length; } - S.walls = walls; S.items = items; S.rooms = rooms; -} - -function roomCenter(rm) { return { x: rm.x + rm.w / 2, z: rm.z + rm.h / 2 }; } -function pickTarget(rb) { - const rm = S.rooms[Math.floor(Math.random() * S.rooms.length)]; - const c = roomCenter(rm); - rb.target = { x: c.x + (Math.random() - .5) * (rm.w - 6), z: c.z + (Math.random() - .5) * (rm.h - 6) }; + const sx = sz / 2, sz2 = sz / 2; // shared elevator shaft at the center + for (let f = 0; f < S.floors - 1; f++) S.elevators.push({ x: sx, z: sz2, from: f, to: f + 1 }); } +const elevatorsOn = (f) => S.elevators.filter(e => e.from === f || e.to === f); +const SHAFT = () => ({ x: S.size / 2, z: S.size / 2 }); -// ── build the Rapier world ───────────────────────────────────────────────── +// ── build the stacked Rapier world ───────────────────────────────────────── async function build() { + S.ready = false; await initPhysics(); buildLayout(); + try { S.world && S.world.free(); } catch {} const w = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); w.timestep = H; - // ground - const g = w.createRigidBody(RAPIER.RigidBodyDesc.fixed()); - w.createCollider(RAPIER.ColliderDesc.cuboid(S.size, 0.1, S.size) - .setTranslation(S.size / 2, -0.1, S.size / 2).setFriction(0.8), g); - // walls (fixed cuboids) - for (const [x1, z1, x2, z2] of S.walls) { - const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2; - const hx = Math.max(Math.abs(x2 - x1) / 2, 0.12), hz = Math.max(Math.abs(z2 - z1) / 2, 0.12); - const b = w.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(cx, 0.6, cz)); - w.createCollider(RAPIER.ColliderDesc.cuboid(hx, 0.6, hz), b); + for (let f = 0; f < S.floors; f++) { + const fy = floorY(f); + const g = w.createRigidBody(RAPIER.RigidBodyDesc.fixed()); + w.createCollider(RAPIER.ColliderDesc.cuboid(S.size, 0.1, S.size) + .setTranslation(S.size / 2, fy - 0.1, S.size / 2).setFriction(0.8), g); + for (const [x1, z1, x2, z2] of S.floorData[f].walls) { + const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2; + const hx = Math.max(Math.abs(x2 - x1) / 2, 0.12), hz = Math.max(Math.abs(z2 - z1) / 2, 0.12); + const b = w.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(cx, fy + 0.6, cz)); + w.createCollider(RAPIER.ColliderDesc.cuboid(hx, 0.6, hz), b); + } } S.world = w; S.ctl = w.createCharacterController(0.02); S.ctl.setApplyImpulsesToDynamicBodies(false); + buildCanvases(); spawnRobots(); S.ready = true; } function spawnRobots() { - // remove old robot bodies for (const rb of S.robots) { try { S.world.removeRigidBody(rb.body); } catch {} } S.robots = []; S.found = 0; S.detected = new Set(); S.t0 = performance.now(); const per = Math.ceil(Math.sqrt(S.n)); for (let i = 0; i < S.n; i++) { - const gx = (i % per), gz = Math.floor(i / per); + const gx = i % per, gz = Math.floor(i / per); const x = 3 + gx * 1.4 + Math.random() * .4, z = 3 + gz * 1.4 + Math.random() * .4; const body = S.world.createRigidBody( - RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(x, 0.3, z)); + RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(x, floorY(0) + 0.3, z)); const col = S.world.createCollider(RAPIER.ColliderDesc.capsule(0.14, 0.3), body); - const rb = { id: "r" + i, body, col, color: COLORS[i % COLORS.length], - heading: Math.random() * 6.28, seen: 0, trail: [], target: null }; + const rb = { id: "r" + i, body, col, color: COLORS[i % COLORS.length], floor: 0, + heading: Math.random() * 6.28, seen: 0, trail: [], inElev: 0, dest: null, target: null }; pickTarget(rb); S.robots.push(rb); } } +function pickTarget(rb) { + const elevs = elevatorsOn(rb.floor); + if (elevs.length && Math.random() < 0.25) { + const e = elevs[Math.floor(Math.random() * elevs.length)]; + rb.target = { x: e.x, z: e.z, elevator: e }; + } else { + const fd = S.floorData[rb.floor], rm = fd.rooms[Math.floor(Math.random() * fd.rooms.length)]; + rb.target = { x: rm.x + rm.w / 2 + (Math.random() - .5) * (rm.w - 6), + z: rm.z + rm.h / 2 + (Math.random() - .5) * (rm.h - 6) }; + } +} + function detect(rb) { - const cur = rb.body.translation(); - for (let k = 0; k < S.items.length; k++) { - const it = S.items[k]; + const cur = rb.body.translation(), items = S.floorData[rb.floor].items; + for (let k = 0; k < items.length; k++) { + const it = items[k]; if (Math.hypot(it.x - cur.x, it.z - cur.z) < 2.2) { - if (!S.detected.has(k)) { S.detected.add(k); S.found++; report(rb, it); } + const key = rb.floor + ":" + k; + if (!S.detected.has(key)) { S.detected.add(key); S.found++; report(rb, it); } rb.seen++; } } } function report(rb, it) { - // real fleet data → the active project/environment store const p = rb.body.translation(); fetch("/api/fleet/observe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ robot_id: rb.id, pose: { x: p.x, y: p.z }, @@ -113,10 +122,26 @@ function report(rb, it) { function stepOnce() { const v = 1.6; for (const rb of S.robots) { + if (rb.inElev > 0) { // riding the elevator + rb.inElev -= H; + if (rb.inElev <= 0 && rb.dest != null) { + rb.floor = rb.dest; rb.dest = null; + const s = SHAFT(); + rb.body.setTranslation({ x: s.x, y: floorY(rb.floor) + 0.3, z: s.z }, true); + rb.trail = []; pickTarget(rb); + } + continue; + } const cur = rb.body.translation(); let dx = rb.target.x - cur.x, dz = rb.target.z - cur.z; const d = Math.hypot(dx, dz); - if (d < 1.0) { detect(rb); pickTarget(rb); continue; } + if (d < 1.0) { + if (rb.target.elevator) { // step into the shaft → other floor + const e = rb.target.elevator; + rb.dest = (e.from === rb.floor) ? e.to : e.from; rb.inElev = 0.8; + } else { detect(rb); pickTarget(rb); } + continue; + } dx /= d; dz /= d; rb.heading = Math.atan2(dz, dx); S.ctl.computeColliderMovement(rb.col, { x: dx * v * H, y: 0, z: dz * v * H }); const mv = S.ctl.computedMovement(); @@ -130,75 +155,89 @@ function stepOnce() { S.world.step(); } -// ── render: top-down view of the true physics positions ──────────────────── -function draw() { - const cv = S.cv; if (!cv) return; - const box = cv.clientWidth || 600; const dpr = window.devicePixelRatio || 1; - if (cv.width !== Math.round(box * dpr)) { cv.width = Math.round(box * dpr); cv.height = Math.round(box * dpr); } - const g = cv.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0); - const W = box, P = 14, sc = (W - 2 * P) / S.size; - const X = x => P + x * sc, Y = z => P + z * sc; - g.clearRect(0, 0, W, W); - g.fillStyle = "#080b08"; g.fillRect(0, 0, W, W); - // rooms (faint) - g.strokeStyle = "rgba(120,175,144,.08)"; g.lineWidth = 1; - for (const rm of S.rooms) g.strokeRect(X(rm.x), Y(rm.z), rm.w * sc, rm.h * sc); - // walls - g.strokeStyle = "#2c3b2c"; g.lineWidth = 3; g.lineCap = "round"; g.beginPath(); - for (const [x1, z1, x2, z2] of S.walls) { g.moveTo(X(x1), Y(z1)); g.lineTo(X(x2), Y(z2)); } - g.stroke(); - // items - for (let k = 0; k < S.items.length; k++) { - const it = S.items[k], on = S.detected.has(k); - g.fillStyle = on ? "rgba(0,212,126,.95)" : "rgba(120,175,144,.32)"; - g.beginPath(); g.arc(X(it.x), Y(it.z), on ? 4 : 2.4, 0, 6.28); g.fill(); - if (on) { g.fillStyle = "rgba(0,212,126,.7)"; g.font = "9px ui-monospace,monospace"; g.fillText(it.label, X(it.x) + 6, Y(it.z) + 3); } +// ── render: one top-down canvas per floor (true physics positions) ───────── +function buildCanvases() { + const host = $("#floors"); if (!host) return; + host.innerHTML = ""; S.canvases = []; + for (let f = 0; f < S.floors; f++) { + const div = document.createElement("div"); div.className = "floorp"; + div.innerHTML = `

Floor ${f}

`; + const cv = document.createElement("canvas"); cv.width = 520; cv.height = 520; + div.appendChild(cv); host.appendChild(div); S.canvases[f] = cv; } - // robots (real Rapier positions) + trails - for (const rb of S.robots) { - if (rb.trail.length > 1) { - g.strokeStyle = rb.color + "33"; g.lineWidth = 1.5; g.beginPath(); - rb.trail.forEach((t, i) => i ? g.lineTo(X(t.x), Y(t.z)) : g.moveTo(X(t.x), Y(t.z))); g.stroke(); +} +function draw() { + for (let f = 0; f < S.floors; f++) { + const cv = S.canvases[f]; if (!cv) continue; + const box = cv.clientWidth || 500, dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(box * dpr)) { cv.width = Math.round(box * dpr); cv.height = Math.round(box * dpr); } + const g = cv.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0); + const W = box, P = 12, sc = (W - 2 * P) / S.size, X = x => P + x * sc, Y = z => P + z * sc; + g.clearRect(0, 0, W, W); g.fillStyle = "#080b08"; g.fillRect(0, 0, W, W); + const fd = S.floorData[f]; + g.strokeStyle = "rgba(120,175,144,.08)"; g.lineWidth = 1; + for (const rm of fd.rooms) g.strokeRect(X(rm.x), Y(rm.z), rm.w * sc, rm.h * sc); + g.strokeStyle = "#2c3b2c"; g.lineWidth = 2.5; g.lineCap = "round"; g.beginPath(); + for (const [x1, z1, x2, z2] of fd.walls) { g.moveTo(X(x1), Y(z1)); g.lineTo(X(x2), Y(z2)); } + g.stroke(); + for (let k = 0; k < fd.items.length; k++) { + const it = fd.items[k], on = S.detected.has(f + ":" + k); + g.fillStyle = on ? "rgba(0,212,126,.95)" : "rgba(120,175,144,.32)"; + g.beginPath(); g.arc(X(it.x), Y(it.z), on ? 4 : 2.4, 0, 6.28); g.fill(); + if (on) { g.fillStyle = "rgba(0,212,126,.7)"; g.font = "9px ui-monospace,monospace"; g.fillText(it.label, X(it.x) + 6, Y(it.z) + 3); } + } + for (const e of elevatorsOn(f)) { + g.strokeStyle = "#4090e0"; g.fillStyle = "rgba(64,144,224,.14)"; g.lineWidth = 1.5; + const s = 3.4 * sc; g.fillRect(X(e.x) - s / 2, Y(e.z) - s / 2, s, s); g.strokeRect(X(e.x) - s / 2, Y(e.z) - s / 2, s, s); + g.fillStyle = "#4090e0"; g.font = "11px ui-monospace,monospace"; g.fillText("⇅", X(e.x) - 4, Y(e.z) + 4); } - const t = rb.body.translation(); const x = X(t.x), y = Y(t.z), h = rb.heading; - g.fillStyle = rb.color; g.beginPath(); - g.moveTo(x + Math.cos(h) * 7, y + Math.sin(h) * 7); - g.lineTo(x + Math.cos(h + 2.5) * 5.5, y + Math.sin(h + 2.5) * 5.5); - g.lineTo(x + Math.cos(h - 2.5) * 5.5, y + Math.sin(h - 2.5) * 5.5); - g.closePath(); g.fill(); + let cnt = 0; + for (const rb of S.robots) { + if (rb.floor !== f || rb.inElev > 0) continue; cnt++; + if (rb.trail.length > 1) { + g.strokeStyle = rb.color + "33"; g.lineWidth = 1.5; g.beginPath(); + rb.trail.forEach((t, i) => i ? g.lineTo(X(t.x), Y(t.z)) : g.moveTo(X(t.x), Y(t.z))); g.stroke(); + } + const t = rb.body.translation(), x = X(t.x), y = Y(t.z), h = rb.heading; + g.fillStyle = rb.color; g.beginPath(); + g.moveTo(x + Math.cos(h) * 7, y + Math.sin(h) * 7); + g.lineTo(x + Math.cos(h + 2.5) * 5.5, y + Math.sin(h + 2.5) * 5.5); + g.lineTo(x + Math.cos(h - 2.5) * 5.5, y + Math.sin(h - 2.5) * 5.5); + g.closePath(); g.fill(); + } + const fb = document.getElementById("fb" + f); if (fb) fb.textContent = cnt + " bots"; } } - function hud() { - const cov = S.items.length ? Math.round(S.found / S.items.length * 100) : 0; + const cov = S.totalItems ? Math.round(S.found / S.totalItems * 100) : 0; const el = (performance.now() - S.t0) / 1000; const tile = (n, l) => `
${n}
${l}
`; - $("#kpis").innerHTML = tile(S.robots.length, "robots") + tile(S.found + "/" + S.items.length, "found") + + $("#kpis").innerHTML = tile(S.robots.length, "robots") + tile(S.found + "/" + S.totalItems, "found") + tile(cov + "%", "coverage") + tile(el.toFixed(0) + "s", "elapsed"); } let last = performance.now(), acc = 0; function loop(now) { if (S.ready) { - let dt = Math.min(0.05, (now - last) / 1000) * S.speed; last = now; + const dt = Math.min(0.05, (now - last) / 1000) * S.speed; last = now; if (S.playing) { acc += dt; let guard = 0; while (acc >= H && guard++ < 240) { stepOnce(); acc -= H; } } draw(); hud(); - } else { last = now; } + } else last = now; requestAnimationFrame(loop); } // ── controls + boot ──────────────────────────────────────────────────────── function wire() { - S.cv = $("#stage"); $("#n") && ($("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; if (S.ready) spawnRobots(); }); + $("#fl") && ($("#fl").oninput = e => { S.floors = +e.target.value; $("#flab").textContent = S.floors; build(); }); $("#spd") && ($("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; }); $("#play") && ($("#play").onclick = () => { S.playing = !S.playing; $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; }); $("#reset") && ($("#reset").onclick = () => { if (S.ready) spawnRobots(); }); fetch("/api/projects/active").then(r => r.json()).then(d => { const s = $("#scope"); if (!s) return; s.innerHTML = d.active - ? `Real Rapier physics — ${S.n} robots collecting jointly into ${d.active.project} / ${d.active.environment}. Every detection lands in that environment's search + spatial map.` - : `Real Rapier physics — ${S.n} robots in one warehouse. Detections go to the active project (currently scratch). Pick a project to scope them.`; + ? `Real Rapier physics — robots across ${S.floors} floors (via elevators) collecting jointly into ${d.active.project} / ${d.active.environment}. Every detection lands in that environment's search + spatial map.` + : `Real Rapier physics — robots across floors via elevators, in one warehouse. Detections go to the active project (currently scratch).`; }).catch(() => {}); } From 9660fe763d9a5d6e09cff51675eaa9120994f0cf Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 17 Jun 2026 17:10:59 -0400 Subject: [PATCH 106/126] =?UTF-8?q?Cockpit:=20always-visible=20=E2=86=90?= =?UTF-8?q?=20Dashboard=20button;=20setup:=20drop=20double-numbering=20+?= =?UTF-8?q?=20premature=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /sim was a full-screen cockpit with no obvious way back (the shell can't wrap the immersive 3D view). Added a fixed top-left ← Dashboard button. - /setup: removed the redundant per-card step number (the top stepper already numbers steps — it read '1 ... 1') and stopped showing the full launch chain (dog · dog-sandbox on rapier → …) on earlier steps, which made it look like choices were already made. Earlier steps now show 'Step X of 4'; the full 'Ready to launch — …' summary appears only on the final step. --- roborun/web/arena.html | 9 +++++++++ roborun/web/setup.html | 16 +++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/roborun/web/arena.html b/roborun/web/arena.html index 45dfdef..706f8ad 100644 --- a/roborun/web/arena.html +++ b/roborun/web/arena.html @@ -57,6 +57,13 @@ #toolbar button.accent { color: #e0c050; border-color: #4a3f1f; } #toolbar .sep { width: 1px; background: #1f2a33; margin: 0 2px; } + /* always-visible way back to the dashboard (the cockpit is full-screen, no shell) */ + #ck-home { position: fixed; top: 10px; left: 12px; z-index: 56; text-decoration: none; + background: rgba(14,19,16,.92); color: #aebcc7; border: 1px solid #2a3a46; + border-radius: 7px; padding: 7px 13px; font-size: 13px; letter-spacing: .02em; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: color .12s, border-color .12s; } + #ck-home:hover { color: #00d47e; border-color: #1f4434; } + /* mission */ #rooms { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; } .room-chip { border: 1px solid #2a3a46; border-radius: 4px; padding: 2px 8px; color: #6b7b88; } @@ -564,6 +571,8 @@
+← Dashboard +
diff --git a/roborun/web/setup.html b/roborun/web/setup.html index d7c44d9..d513d5e 100644 --- a/roborun/web/setup.html +++ b/roborun/web/setup.html @@ -118,7 +118,7 @@
-

1 Project where the data lives

+

Project where the data lives

Everything you record scopes to a project. Reuse one, make a new one, or use the throwaway scratch space.

@@ -128,13 +128,13 @@

1 Project where the data lives

ResultScenarioSuite Seed