infinicode 2.8.13 → 2.8.16

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 (38) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +32 -1
  3. package/dist/robopark/auto-start.d.ts +1 -1
  4. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  5. package/dist/robopark/preview-agent-launcher.js +8 -0
  6. package/dist/robopark/python-env.d.ts +10 -4
  7. package/dist/robopark/python-env.js +28 -16
  8. package/dist/robopark/setup.d.ts +1 -0
  9. package/dist/robopark/setup.js +22 -0
  10. package/dist/robopark/vision-agent-launcher.d.ts +7 -0
  11. package/dist/robopark/vision-agent-launcher.js +55 -0
  12. package/dist/robopark-cli.js +16 -0
  13. package/package.json +3 -1
  14. package/packages/robopark/pi-client/README.md +17 -0
  15. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  16. package/packages/robopark/pi-client/client.py +305 -0
  17. package/packages/robopark/pi-client/install.sh +41 -0
  18. package/packages/robopark/pi-client/join_convo.sh +55 -0
  19. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  20. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  21. package/packages/robopark/pi-client/pi_ui.py +375 -0
  22. package/packages/robopark/pi-client/requirements.txt +10 -0
  23. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  24. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  25. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  26. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  27. package/packages/robopark/scheduler/main.py +45 -9
  28. package/packages/robopark/scheduler/preview_agent.py +132 -3
  29. package/packages/robopark/vision/.env.example +7 -0
  30. package/packages/robopark/vision/README.md +66 -0
  31. package/packages/robopark/vision/app_pi_clean.py +326 -0
  32. package/packages/robopark/vision/audio_server_pi.py +751 -0
  33. package/packages/robopark/vision/install.sh +34 -0
  34. package/packages/robopark/vision/motor_server.py +304 -0
  35. package/packages/robopark/vision/requirements-demo.txt +12 -0
  36. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  37. package/packages/robopark/vision/run.sh +244 -0
  38. package/packages/robopark/vision/services/services.sh +12 -0
@@ -0,0 +1,82 @@
1
+ """Motor bridge for RoboPark Pi client.
2
+
3
+ Translates motor commands (currently delivered via LiveKit data channel
4
+ from the agent) into HTTP POSTs against the local RoboVisionAI_PI motor
5
+ server (same endpoints the agent already calls: /trigger-motor and
6
+ /stop-motors).
7
+
8
+ Command wire format (JSON on the data channel):
9
+ {"op": "motor", "name": "arm", "seconds": 3}
10
+ {"op": "stop"}
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ from typing import Awaitable, Callable, Optional
17
+
18
+ import httpx
19
+
20
+ log = logging.getLogger("robopark-pi.motor")
21
+
22
+
23
+ class MotorBridge:
24
+ def __init__(self, motor_server_url: Optional[str], dry_run: bool = False):
25
+ self.motor_server_url = (motor_server_url or "").rstrip("/")
26
+ self.dry_run = dry_run
27
+ self._lock = asyncio.Lock() # the local motor server allows one motor at a time
28
+
29
+ async def handle_command(self, raw: str | bytes) -> str:
30
+ """Handle one command from the data channel. Returns a short result string."""
31
+ try:
32
+ text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
33
+ msg = json.loads(text)
34
+ except Exception as e:
35
+ log.warning(f"Could not parse motor command {raw!r}: {e}")
36
+ return f"error: invalid json ({e})"
37
+
38
+ op = (msg.get("op") or "").lower()
39
+ if op == "motor":
40
+ name = msg.get("name")
41
+ seconds = int(msg.get("seconds", 3))
42
+ if not name:
43
+ return "error: missing 'name'"
44
+ seconds = max(1, min(seconds, 30))
45
+ return await self.move(name, seconds)
46
+ elif op == "stop":
47
+ return await self.stop_all()
48
+ elif op == "ping":
49
+ return "pong"
50
+ else:
51
+ log.warning(f"Unknown motor op: {op!r}")
52
+ return f"error: unknown op {op!r}"
53
+
54
+ async def move(self, name: str, seconds: int) -> str:
55
+ if self.dry_run or not self.motor_server_url:
56
+ log.info(f"[DRY-RUN] would move motor '{name}' for {seconds}s")
57
+ return f"dry-run: {name} {seconds}s"
58
+ async with self._lock:
59
+ try:
60
+ async with httpx.AsyncClient(timeout=seconds + 5.0) as c:
61
+ r = await c.post(
62
+ f"{self.motor_server_url}/trigger-motor",
63
+ json={"motor_name": name, "seconds": seconds},
64
+ )
65
+ if r.status_code == 200:
66
+ return f"moved {name} for {seconds}s"
67
+ return f"motor server {r.status_code}: {r.text[:200]}"
68
+ except Exception as e:
69
+ log.error(f"move({name}) failed: {e}")
70
+ return f"error: {e}"
71
+
72
+ async def stop_all(self) -> str:
73
+ if self.dry_run or not self.motor_server_url:
74
+ log.info("[DRY-RUN] would stop all motors")
75
+ return "dry-run: stop"
76
+ try:
77
+ async with httpx.AsyncClient(timeout=5.0) as c:
78
+ r = await c.post(f"{self.motor_server_url}/stop-motors", json={})
79
+ return f"stop {r.status_code}"
80
+ except Exception as e:
81
+ log.error(f"stop_all failed: {e}")
82
+ return f"error: {e}"
@@ -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()