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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark/python-env.d.ts +10 -4
- package/dist/robopark/python-env.js +28 -16
- package/dist/robopark/setup.d.ts +1 -0
- package/dist/robopark/setup.js +22 -0
- package/dist/robopark/vision-agent-launcher.d.ts +7 -0
- package/dist/robopark/vision-agent-launcher.js +55 -0
- package/dist/robopark-cli.js +16 -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 +66 -0
- package/packages/robopark/vision/app_pi_clean.py +326 -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-demo.txt +12 -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,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()
|