infinicode 2.8.13 → 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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark-cli.js +3 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +63 -0
- package/packages/robopark/vision/app_pi_clean.py +307 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- package/packages/robopark/vision/services/services.sh +12 -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"
|
|
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.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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">
|