infinicode 2.8.12 → 2.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +49 -9
  3. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  4. package/dist/robopark/preview-agent-launcher.js +8 -0
  5. package/dist/robopark-cli.js +3 -0
  6. package/package.json +3 -1
  7. package/packages/robopark/pi-client/README.md +17 -0
  8. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  9. package/packages/robopark/pi-client/client.py +305 -0
  10. package/packages/robopark/pi-client/install.sh +41 -0
  11. package/packages/robopark/pi-client/join_convo.sh +55 -0
  12. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  13. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  14. package/packages/robopark/pi-client/pi_ui.py +375 -0
  15. package/packages/robopark/pi-client/requirements.txt +10 -0
  16. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  17. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  18. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  19. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  20. package/packages/robopark/scheduler/main.py +45 -9
  21. package/packages/robopark/scheduler/preview_agent.py +132 -3
  22. package/packages/robopark/vision/.env.example +7 -0
  23. package/packages/robopark/vision/README.md +63 -0
  24. package/packages/robopark/vision/app_pi_clean.py +307 -0
  25. package/packages/robopark/vision/audio_server_pi.py +751 -0
  26. package/packages/robopark/vision/install.sh +34 -0
  27. package/packages/robopark/vision/motor_server.py +304 -0
  28. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  29. package/packages/robopark/vision/run.sh +244 -0
  30. package/packages/robopark/vision/services/services.sh +12 -0
  31. package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  32. package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,375 @@
1
+ """Lightweight Pi-side UI for testing the production-mode + LiveKit flow.
2
+
3
+ Run on the Pi:
4
+ /opt/robopark-pi-client/.venv/bin/python /opt/robopark-pi-client/pi_ui.py \
5
+ --scheduler-url http://192.168.1.241:8080 \
6
+ --port 8081
7
+
8
+ Then open http://192.168.1.159:8081/ in any browser on the same LAN.
9
+
10
+ What it shows / does:
11
+ - Live status of this device as seen by the scheduler (online/offline, last heartbeat)
12
+ - Current scheduler settings (production_mode, LiveKit configured)
13
+ - Big "Join Conversation" button that asks the scheduler for a LiveKit token and
14
+ opens https://meet.livekit.io/custom?livekitUrl=...&token=... in a new tab
15
+ - "Send motor command" widget that fires a JSON command into the MotorBridge
16
+ (same wire format as the LiveKit data channel)
17
+ - "Tail logs" pane that shows the last lines of the robopark-pi-client service
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import logging
23
+ import os
24
+ import subprocess
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ import httpx
29
+ from flask import Flask, jsonify, render_template_string, request
30
+
31
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
32
+ log = logging.getLogger("robopark-pi.ui")
33
+
34
+ app = Flask(__name__)
35
+
36
+ # Lazy-loaded MotorBridge import so the UI can start even if the SDK is missing.
37
+ _BRIDGE = None
38
+ def _bridge():
39
+ global _BRIDGE
40
+ if _BRIDGE is None:
41
+ try:
42
+ from motor_bridge import MotorBridge
43
+ _BRIDGE = MotorBridge(
44
+ motor_server_url=os.environ.get("MOTOR_SERVER_URL", "http://127.0.0.1:8001"),
45
+ dry_run=os.environ.get("DRY_RUN_MOTORS", "0") == "1",
46
+ )
47
+ except Exception as e:
48
+ log.warning(f"MotorBridge unavailable: {e}")
49
+ _BRIDGE = None
50
+ return _BRIDGE
51
+
52
+ STATE_FILE = Path(os.environ.get("STATE_FILE", "/etc/robopark/device.json"))
53
+
54
+
55
+ def _load_device() -> dict:
56
+ if not STATE_FILE.exists():
57
+ return {}
58
+ try:
59
+ return json.loads(STATE_FILE.read_text())
60
+ except Exception as e:
61
+ log.warning(f"could not read {STATE_FILE}: {e}")
62
+ return {}
63
+
64
+
65
+ def _read_service_log(lines: int = 60) -> str:
66
+ try:
67
+ out = subprocess.run(
68
+ ["journalctl", "-u", "robopark-pi-client", "-n", str(lines), "--no-pager", "-o", "cat"],
69
+ capture_output=True, text=True, timeout=5,
70
+ )
71
+ return out.stdout
72
+ except Exception as e:
73
+ return f"(could not read journal: {e})"
74
+
75
+
76
+ HTML = r"""
77
+ <!doctype html>
78
+ <html><head>
79
+ <title>RoboPark Pi Console</title>
80
+ <meta charset="utf-8">
81
+ <meta name="viewport" content="width=device-width,initial-scale=1">
82
+ <style>
83
+ * { box-sizing: border-box; }
84
+ body { font: 14px/1.4 -apple-system, system-ui, Segoe UI, sans-serif;
85
+ background: #0f172a; color: #e2e8f0; margin: 0; padding: 24px; }
86
+ h1 { margin: 0 0 8px; }
87
+ h2 { margin: 24px 0 8px; font-size: 16px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; }
88
+ .card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
89
+ .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
90
+ .pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
91
+ .pill.green { background: #14532d; color: #86efac; }
92
+ .pill.red { background: #7f1d1d; color: #fca5a5; }
93
+ .pill.gray { background: #1f2937; color: #d1d5db; }
94
+ .pill.yellow { background: #78350f; color: #fde68a; }
95
+ .pill.blue { background: #1e3a8a; color: #bfdbfe; }
96
+ button { background: #2563eb; color: white; border: 0; padding: 8px 16px;
97
+ border-radius: 6px; font-size: 14px; cursor: pointer; }
98
+ button:hover { background: #1d4ed8; }
99
+ button:disabled { background: #334155; color: #94a3b8; cursor: not-allowed; }
100
+ button.danger { background: #dc2626; } button.danger:hover { background: #b91c1c; }
101
+ button.success { background: #16a34a; } button.success:hover { background: #15803d; }
102
+ button.muted { background: #475569; } button.muted:hover { background: #334155; }
103
+ pre { background: #020617; color: #e2e8f0; padding: 12px; border-radius: 6px;
104
+ overflow-x: auto; font-size: 12px; line-height: 1.4; }
105
+ input, select { background: #0f172a; color: #e2e8f0; border: 1px solid #334155;
106
+ padding: 6px 10px; border-radius: 4px; font: inherit; }
107
+ .status { display: flex; gap: 24px; flex-wrap: wrap; }
108
+ .status div { min-width: 140px; }
109
+ .status b { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 2px; }
110
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
111
+ @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
112
+ .small { font-size: 12px; color: #94a3b8; }
113
+ .mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
114
+ </style>
115
+ </head><body>
116
+ <h1>🤖 RoboPark Pi Console</h1>
117
+ <p class="small" id="hostname">ceopi1</p>
118
+
119
+ <div class="card">
120
+ <h2>This device</h2>
121
+ <div class="status" id="status">
122
+ <div><b>Device ID</b><span class="mono" id="device_id">—</span></div>
123
+ <div><b>Status</b><span id="status_pill" class="pill gray">…</span></div>
124
+ <div><b>Last heartbeat</b><span id="last_hb" class="mono">—</span></div>
125
+ <div><b>Motor server</b><span id="motor_url" class="mono">—</span></div>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="grid">
130
+ <div class="card">
131
+ <h2>Scheduler</h2>
132
+ <div class="status">
133
+ <div><b>URL</b><span class="mono" id="sched_url">—</span></div>
134
+ <div><b>Production mode</b><span id="prod_pill" class="pill gray">…</span></div>
135
+ <div><b>LiveKit</b><span id="lk_pill" class="pill gray">…</span></div>
136
+ </div>
137
+ <div class="row" style="margin-top:12px">
138
+ <button id="prod_toggle" class="muted">Toggle production mode</button>
139
+ <button id="refresh" class="muted">Refresh</button>
140
+ </div>
141
+ </div>
142
+
143
+ <div class="card">
144
+ <h2>LiveKit — join conversation</h2>
145
+ <p class="small">Asks the scheduler for a short-lived token, then opens
146
+ <code>meet.livekit.io</code> in a new tab. You can publish mic + cam and
147
+ hear the agent; the Pi's audio hardware is forwarded by the main client.</p>
148
+ <div class="row">
149
+ <label>Room <input id="room" value="robopark-pi-test" class="mono" style="width:200px"></label>
150
+ <label>Identity <input id="identity" value="" class="mono" style="width:200px"
151
+ placeholder="auto"></label>
152
+ </div>
153
+ <div class="row" style="margin-top:12px">
154
+ <button id="join" class="success">Join conversation</button>
155
+ </div>
156
+ <p class="small" id="join_msg" style="margin-top:8px"></p>
157
+ </div>
158
+ </div>
159
+
160
+ <div class="card">
161
+ <h2>Motor test</h2>
162
+ <p class="small">Fires the same JSON payload the LiveKit data channel would send
163
+ into the local motor bridge. No wiring needed.</p>
164
+ <div class="row">
165
+ <label>Motor <input id="motor_name" value="arm" class="mono" style="width:120px"></label>
166
+ <label>Seconds <input id="motor_secs" value="2" type="number" min="1" max="30" style="width:80px"></label>
167
+ <button id="motor_go" class="success">Move</button>
168
+ <button id="motor_stop" class="danger">Stop all</button>
169
+ </div>
170
+ <p class="small" id="motor_msg" style="margin-top:8px"></p>
171
+ </div>
172
+
173
+ <div class="card">
174
+ <h2>Service log (last 60 lines)</h2>
175
+ <pre id="log">(loading…)</pre>
176
+ <div class="row" style="margin-top:8px">
177
+ <button id="log_refresh" class="muted">Refresh log</button>
178
+ </div>
179
+ </div>
180
+
181
+ <script>
182
+ async function fetchJSON(url, opts) {
183
+ const r = await fetch(url, opts);
184
+ if (!r.ok) throw new Error("HTTP " + r.status + " " + (await r.text()).slice(0, 200));
185
+ return r.json();
186
+ }
187
+
188
+ function fmtTime(iso) {
189
+ if (!iso) return "—";
190
+ try { return new Date(iso).toLocaleString(); } catch { return iso; }
191
+ }
192
+
193
+ async function refresh() {
194
+ try {
195
+ const s = await fetchJSON("/api/status");
196
+ document.getElementById("device_id").textContent = s.device_id || "(not enrolled)";
197
+ const pill = document.getElementById("status_pill");
198
+ pill.textContent = s.status || "unknown";
199
+ pill.className = "pill " + (s.status === "online" ? "green"
200
+ : s.status === "offline" ? "red" : "gray");
201
+ document.getElementById("last_hb").textContent = fmtTime(s.last_heartbeat);
202
+ document.getElementById("motor_url").textContent = s.motor_server_url || "—";
203
+ document.getElementById("sched_url").textContent = s.scheduler_url;
204
+ const pp = document.getElementById("prod_pill");
205
+ pp.textContent = s.production_mode ? "ON" : "OFF";
206
+ pp.className = "pill " + (s.production_mode ? "green" : "gray");
207
+ const lp = document.getElementById("lk_pill");
208
+ if (s.livekit_configured) { lp.textContent = s.livekit_url || "set"; lp.className = "pill blue"; }
209
+ else { lp.textContent = "NOT SET"; lp.className = "pill yellow"; }
210
+ if (!document.getElementById("identity").value) {
211
+ document.getElementById("identity").placeholder = "pi-ui-" + (s.device_id || "anon");
212
+ }
213
+ } catch (e) { console.error(e); }
214
+ }
215
+
216
+ async function toggleProd() {
217
+ const s = await fetchJSON("/api/status");
218
+ await fetchJSON("/api/production_mode", { method: "PUT",
219
+ headers: {"Content-Type":"application/json"},
220
+ body: JSON.stringify({ production_mode: !s.production_mode }) });
221
+ await refresh();
222
+ }
223
+
224
+ async function join() {
225
+ const room = document.getElementById("room").value.trim() || "robopark-pi-test";
226
+ const identity = document.getElementById("identity").value.trim()
227
+ || "pi-ui-" + Math.random().toString(36).slice(2,8);
228
+ const msg = document.getElementById("join_msg");
229
+ msg.textContent = "requesting token...";
230
+ try {
231
+ const t = await fetchJSON("/api/join", { method: "POST",
232
+ headers: {"Content-Type":"application/json"},
233
+ body: JSON.stringify({ room, identity }) });
234
+ const url = "https://meet.livekit.io/custom?liveKitUrl="
235
+ + encodeURIComponent(t.url) + "&token=" + encodeURIComponent(t.token);
236
+ window.open(url, "_blank");
237
+ msg.textContent = "Token expires " + fmtTime(t.expires_at) + ". Opened in a new tab.";
238
+ } catch (e) { msg.textContent = "error: " + e.message; }
239
+ }
240
+
241
+ async function motor(cmd) {
242
+ const msg = document.getElementById("motor_msg");
243
+ try {
244
+ const r = await fetchJSON("/api/motor", { method: "POST",
245
+ headers: {"Content-Type":"application/json"}, body: JSON.stringify(cmd) });
246
+ msg.textContent = JSON.stringify(r);
247
+ } catch (e) { msg.textContent = "error: " + e.message; }
248
+ }
249
+
250
+ async function refreshLog() {
251
+ try {
252
+ const r = await fetchJSON("/api/log");
253
+ document.getElementById("log").textContent = r.lines || "(empty)";
254
+ } catch (e) { document.getElementById("log").textContent = "error: " + e.message; }
255
+ }
256
+
257
+ document.getElementById("refresh").onclick = refresh;
258
+ document.getElementById("prod_toggle").onclick = toggleProd;
259
+ document.getElementById("join").onclick = join;
260
+ document.getElementById("motor_go").onclick = () => motor({
261
+ op: "motor",
262
+ name: document.getElementById("motor_name").value,
263
+ seconds: parseInt(document.getElementById("motor_secs").value, 10) || 2,
264
+ });
265
+ document.getElementById("motor_stop").onclick = () => motor({ op: "stop" });
266
+ document.getElementById("log_refresh").onclick = refreshLog;
267
+
268
+ refresh();
269
+ refreshLog();
270
+ setInterval(refresh, 5000);
271
+ setInterval(refreshLog, 5000);
272
+ </script>
273
+ </body></html>
274
+ """
275
+
276
+
277
+ @app.route("/")
278
+ def index():
279
+ return render_template_string(HTML)
280
+
281
+
282
+ @app.route("/api/status")
283
+ def api_status():
284
+ dev = _load_device()
285
+ out = {
286
+ "scheduler_url": app.config["SCHEDULER_URL"],
287
+ "device_id": dev.get("device_id"),
288
+ "status": None,
289
+ "last_heartbeat": None,
290
+ "motor_server_url": dev.get("motor_server_url"),
291
+ "production_mode": None,
292
+ "livekit_configured": False,
293
+ "livekit_url": None,
294
+ }
295
+ try:
296
+ with httpx.Client(timeout=5.0) as c:
297
+ r = c.get(f"{app.config['SCHEDULER_URL']}/api/settings")
298
+ if r.status_code == 200:
299
+ s = r.json()
300
+ out["production_mode"] = bool(s.get("production_mode"))
301
+ r2 = c.get(f"{app.config['SCHEDULER_URL']}/api/livekit/config")
302
+ if r2.status_code == 200:
303
+ lk = r2.json()
304
+ out["livekit_configured"] = bool(lk.get("url") and lk.get("has_secret"))
305
+ out["livekit_url"] = lk.get("url")
306
+ if dev.get("device_id"):
307
+ r3 = c.get(f"{app.config['SCHEDULER_URL']}/api/devices/{dev['device_id']}")
308
+ if r3.status_code == 200:
309
+ d = r3.json()
310
+ out["status"] = d.get("status")
311
+ out["last_heartbeat"] = d.get("last_heartbeat")
312
+ except Exception as e:
313
+ log.warning(f"status fetch failed: {e}")
314
+ return jsonify(out)
315
+
316
+
317
+ @app.route("/api/production_mode", methods=["PUT"])
318
+ def api_prod_mode():
319
+ payload = request.get_json(force=True) or {}
320
+ new = bool(payload.get("production_mode", False))
321
+ with httpx.Client(timeout=5.0) as c:
322
+ r = c.put(f"{app.config['SCHEDULER_URL']}/api/settings",
323
+ json={"production_mode": new})
324
+ r.raise_for_status()
325
+ return jsonify({"status": "ok", "production_mode": new})
326
+
327
+
328
+ @app.route("/api/join", methods=["POST"])
329
+ def api_join():
330
+ payload = request.get_json(force=True) or {}
331
+ room = payload.get("room") or "robopark-pi-test"
332
+ identity = payload.get("identity") or ("pi-ui-" + os.urandom(3).hex())
333
+ with httpx.Client(timeout=5.0) as c:
334
+ r = c.post(f"{app.config['SCHEDULER_URL']}/api/livekit/token",
335
+ json={"room": room, "identity": identity,
336
+ "name": "Pi UI", "ttl_seconds": 3600})
337
+ if r.status_code != 200:
338
+ return jsonify({"error": r.text}), r.status_code
339
+ return jsonify(r.json())
340
+
341
+
342
+ @app.route("/api/motor", methods=["POST"])
343
+ def api_motor():
344
+ payload = request.get_json(force=True) or {}
345
+ bridge = _bridge()
346
+ if bridge is None:
347
+ return jsonify({"error": "motor bridge not loaded (import failed)"}), 503
348
+ import asyncio
349
+ result = asyncio.run(bridge.handle_command(json.dumps(payload)))
350
+ return jsonify({"result": result})
351
+
352
+
353
+ @app.route("/api/log")
354
+ def api_log():
355
+ return jsonify({"lines": _read_service_log(60)})
356
+
357
+
358
+ def parse_args():
359
+ p = argparse.ArgumentParser()
360
+ p.add_argument("--scheduler-url", required=True)
361
+ p.add_argument("--host", default="0.0.0.0")
362
+ p.add_argument("--port", type=int, default=8081)
363
+ p.add_argument("--debug", action="store_true")
364
+ return p.parse_args()
365
+
366
+
367
+ def main():
368
+ args = parse_args()
369
+ app.config["SCHEDULER_URL"] = args.scheduler_url.rstrip("/")
370
+ log.info(f"Pi UI on http://{args.host}:{args.port}/ -> scheduler {app.config['SCHEDULER_URL']}")
371
+ app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
372
+
373
+
374
+ if __name__ == "__main__":
375
+ main()
@@ -0,0 +1,10 @@
1
+ httpx>=0.27
2
+ flask>=3.0
3
+ # jq is required by join_convo.sh; install with: apt-get install -y jq
4
+ # LiveKit + media (install on Pi only; client works without these)
5
+ # livekit>=0.10
6
+ # livekit-api>=0.10
7
+ # sounddevice>=0.4
8
+ # numpy
9
+ # opencv-python>=4.8
10
+ # av>=11
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=RoboPark Pi Client
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=robopark
9
+ Group=robopark
10
+ WorkingDirectory=/opt/robopark-pi-client
11
+ ExecStart=/opt/robopark-pi-client/.venv/bin/python /opt/robopark-pi-client/client.py \
12
+ --scheduler-url http://192.168.1.241:8080 \
13
+ --name ceopi1 \
14
+ --tailscale-ip "" \
15
+ --lan-ip 192.168.1.159 \
16
+ --motor-server-url http://127.0.0.1:8001 \
17
+ --livekit-url "" \
18
+ --state-file /etc/robopark/device.json \
19
+ --heartbeat-interval 10 \
20
+ --dry-run-motors \
21
+ --join-room
22
+ Restart=on-failure
23
+ RestartSec=5
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target
@@ -0,0 +1,24 @@
1
+ [Unit]
2
+ Description=RoboPark Pi UI (testing/console)
3
+ After=network-online.target robopark-pi-client.service
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=robopark
9
+ Group=robopark
10
+ WorkingDirectory=/opt/robopark-pi-client
11
+ # DRY_RUN_MOTORS=1 makes the motor widget log but not POST to the motor server.
12
+ # Remove that env var once RoboVisionAI_PI is running on the Pi.
13
+ Environment=DRY_RUN_MOTORS=1
14
+ Environment=MOTOR_SERVER_URL=http://127.0.0.1:8001
15
+ Environment=STATE_FILE=/etc/robopark/device.json
16
+ ExecStart=/opt/robopark-pi-client/.venv/bin/python /opt/robopark-pi-client/pi_ui.py \
17
+ --scheduler-url http://192.168.1.241:8080 \
18
+ --host 0.0.0.0 \
19
+ --port 8081
20
+ Restart=on-failure
21
+ RestartSec=5
22
+
23
+ [Install]
24
+ WantedBy=multi-user.target
@@ -0,0 +1,15 @@
1
+ """Drive a fake LiveKit data-channel payload through the MotorBridge to prove
2
+ the wire format end-to-end. This is exactly what livekit_bridge.run_livekit
3
+ will call when the agent publishes {"op":"motor", ...} on the data channel."""
4
+ import asyncio
5
+ from motor_bridge import MotorBridge
6
+
7
+ async def main():
8
+ bridge = MotorBridge("http://127.0.0.1:8765")
9
+ print("--> ping:", await bridge.handle_command('{"op":"ping"}'))
10
+ print("--> motor arm 2s:", await bridge.handle_command('{"op":"motor","name":"arm","seconds":2}'))
11
+ await asyncio.sleep(0.2)
12
+ print("--> stop:", await bridge.handle_command('{"op":"stop"}'))
13
+ print("--> bogus:", await bridge.handle_command('not json'))
14
+
15
+ asyncio.run(main())
@@ -0,0 +1,46 @@
1
+ """Tiny stub of the RoboVisionAI_PI motor server for local smoke tests.
2
+ Listens on :8765 and logs every /trigger-motor and /stop-motors call.
3
+ Run with: python stub_motor_server.py
4
+ """
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ import json, threading, time
7
+
8
+ LOCK = threading.Lock()
9
+ ACTIVE = None # (motor_name, until_ts)
10
+
11
+ class H(BaseHTTPRequestHandler):
12
+ def log_message(self, fmt, *a): pass
13
+ def _send(self, code, body):
14
+ self.send_response(code); self.send_header("Content-Type","application/json"); self.end_headers()
15
+ self.wfile.write(json.dumps(body).encode())
16
+ def do_POST(self):
17
+ global ACTIVE
18
+ ln = int(self.headers.get("Content-Length","0"))
19
+ body = json.loads(self.rfile.read(ln) or b"{}")
20
+ if self.path == "/trigger-motor":
21
+ with LOCK:
22
+ now = time.time()
23
+ if ACTIVE and ACTIVE[1] > now:
24
+ self._send(409, {"error":"busy", "active": ACTIVE[0]})
25
+ return
26
+ name = body["motor_name"]; secs = int(body["seconds"])
27
+ ACTIVE = (name, now + secs)
28
+ print(f"[stub] TRIGGER {name} for {secs}s")
29
+ threading.Timer(secs, lambda: (print(f"[stub] DONE {name}"), _clear(name))).start()
30
+ self._send(200, {"ok": True, "motor": name, "seconds": secs})
31
+ elif self.path == "/stop-motors":
32
+ with LOCK:
33
+ ACTIVE = None
34
+ print("[stub] STOP all")
35
+ self._send(200, {"ok": True})
36
+ else:
37
+ self._send(404, {"error":"unknown path"})
38
+
39
+ def _clear(name):
40
+ global ACTIVE
41
+ with LOCK:
42
+ if ACTIVE and ACTIVE[0] == name:
43
+ ACTIVE = None
44
+
45
+ if __name__ == "__main__":
46
+ HTTPServer(("127.0.0.1", 8765), H).serve_forever()
@@ -505,9 +505,11 @@ async def request_session(robot_id: str,
505
505
  if server["active"] >= server["max_sessions"]:
506
506
  raise HTTPException(503, "All servers at capacity")
507
507
 
508
- # Create session
508
+ # Create session. Room name MUST start with "robopark-" — that's the
509
+ # literal prefix voice_agent.py checks to enable production-mode
510
+ # (character/voice/motor resolution, unlimited idle timeout).
509
511
  session_id = f"session_{robot_id}_{int(datetime.utcnow().timestamp())}"
510
- room_name = f"robopark_{robot_id}_{int(datetime.utcnow().timestamp())}"
512
+ room_name = f"robopark-{robot_id}-{int(datetime.utcnow().timestamp())}"
511
513
 
512
514
  await db.execute("""
513
515
  INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
@@ -1370,6 +1372,7 @@ async def robot_stream(robot_id: str,
1370
1372
  # cam. Cam therefore runs ONLY while an operator is watching.
1371
1373
 
1372
1374
  PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1375
+ DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
1373
1376
 
1374
1377
  async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1375
1378
  """Return a LiveKit server row for a preview: a preferred one if still present,
@@ -1518,12 +1521,29 @@ async def list_devices():
1518
1521
 
1519
1522
  @app.get("/api/devices/by-room/{room_name}")
1520
1523
  async def get_device_by_room(room_name: str):
1521
- """Look up the device bound to a LiveKit room. Convention: room = 'robopark-<device_id>'."""
1522
- if not room_name.startswith("robopark-"):
1523
- raise HTTPException(404, "Room is not a robopark room")
1524
- device_id = room_name[len("robopark-"):]
1524
+ """Look up the device bound to a LiveKit room.
1525
+
1526
+ Resolves via the sessions table (robust to any room-name format the
1527
+ scheduler generates — request-session's robopark-<id>-<ts>, etc.) rather
1528
+ than parsing the name, since a plain prefix-strip broke the moment
1529
+ request-session started including a trailing timestamp. Falls back to a
1530
+ prefix-strip for the standing-room convention (robopark-<device_id>, no
1531
+ trailing timestamp, no sessions row — e.g. robopark-pi-client's always-on
1532
+ room when production_mode is on)."""
1533
+ device_id = None
1525
1534
  async with aiosqlite.connect(DB_PATH) as db:
1526
1535
  db.row_factory = aiosqlite.Row
1536
+ async with db.execute(
1537
+ "SELECT robot_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
1538
+ (room_name,),
1539
+ ) as c:
1540
+ session = await c.fetchone()
1541
+ if session:
1542
+ device_id = session["robot_id"]
1543
+ elif room_name.startswith("robopark-"):
1544
+ device_id = room_name[len("robopark-"):]
1545
+ if not device_id:
1546
+ raise HTTPException(404, "No device bound to this room")
1527
1547
  async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1528
1548
  row = await c.fetchone()
1529
1549
  if not row:
@@ -1889,7 +1909,11 @@ async def device_request_session(device_id: str,
1889
1909
 
1890
1910
  ts = int(datetime.utcnow().timestamp())
1891
1911
  session_id = f"session_{device_id}_{ts}"
1892
- room_name = f"robopark_{device_id}_{ts}"
1912
+ # Room name MUST start with "robopark-" — the literal prefix
1913
+ # voice_agent.py checks to enable production-mode (character/voice/
1914
+ # motor resolution via GET /api/devices/by-room, unlimited idle
1915
+ # timeout instead of the 30s default).
1916
+ room_name = f"robopark-{device_id}-{ts}"
1893
1917
 
1894
1918
  await db.execute("""
1895
1919
  INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
@@ -2027,13 +2051,22 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2027
2051
  Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
2028
2052
  an end_reason breakdown; None if the robot does not exist."""
2029
2053
  async with db.execute(
2030
- "SELECT trigger_count FROM robots WHERE id = ?", (robot_id,)
2054
+ "SELECT trigger_count, name FROM robots WHERE id = ?", (robot_id,)
2031
2055
  ) as c:
2032
2056
  robot = await c.fetchone()
2033
2057
  if not robot:
2034
2058
  return None
2035
2059
  trigger_count = robot["trigger_count"] or 0
2036
2060
 
2061
+ # Pull the device's freshest known LAN address (heartbeat > enrollment) so
2062
+ # the dashboard can reach the robot directly for e.g. the vision overlay
2063
+ # feed, which the scheduler doesn't proxy.
2064
+ async with db.execute(
2065
+ "SELECT last_seen_ip, lan_ip FROM devices WHERE id = ?", (robot_id,)
2066
+ ) as c:
2067
+ dev = await c.fetchone()
2068
+ lan_ip = (dev["last_seen_ip"] or dev["lan_ip"]) if dev else None
2069
+
2037
2070
  # Average latency over sessions that recorded a room join.
2038
2071
  async with db.execute(
2039
2072
  "SELECT started_at, joined_at FROM sessions WHERE robot_id = ? AND joined_at IS NOT NULL",
@@ -2066,6 +2099,9 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2066
2099
 
2067
2100
  return {
2068
2101
  "robot_id": robot_id,
2102
+ "name": robot["name"],
2103
+ "lan_ip": lan_ip,
2104
+ "vision_port": DEFAULT_VISION_PORT,
2069
2105
  "trigger_count": trigger_count,
2070
2106
  "sessions_total": sessions_total,
2071
2107
  "avg_latency_ms": avg_latency_ms,
@@ -2822,7 +2858,7 @@ async def dashboard():
2822
2858
  <video id="preview-video" class="w-full" autoplay playsinline muted></video>
2823
2859
  <div class="text-xs text-gray-400 px-2 py-1">{{ preview.room }}</div>
2824
2860
  </div>
2825
- </div>
2861
+ </div>
2826
2862
 
2827
2863
  <!-- Servers Panel -->
2828
2864
  <div class="col-span-5 space-y-4">