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,305 @@
|
|
|
1
|
+
# RoboPark Pi Client
|
|
2
|
+
# Run on the Raspberry Pi (or any device) to:
|
|
3
|
+
# 1. Enroll with the scheduler (first boot) using --enrollment-token
|
|
4
|
+
# 2. Persist device_id + device_token to --state-file (default ./device.json)
|
|
5
|
+
# 3. Heartbeat every --heartbeat-interval seconds
|
|
6
|
+
# 4. Join a LiveKit room (if `livekit` SDK is installed) to publish mic + camera
|
|
7
|
+
# and subscribe to the agent's data channel for motor commands
|
|
8
|
+
# 5. Forward motor commands to the local RoboVisionAI_PI motor server
|
|
9
|
+
#
|
|
10
|
+
# Quick start:
|
|
11
|
+
# python client.py \
|
|
12
|
+
# --scheduler-url http://100.64.1.5:8080 \
|
|
13
|
+
# --enrollment-token <TOKEN-FROM-SCHEDULER-LOGS> \
|
|
14
|
+
# --name pipi \
|
|
15
|
+
# --tailscale-ip 100.64.1.10 \
|
|
16
|
+
# --lan-ip 192.168.1.159 \
|
|
17
|
+
# --motor-server-url http://192.168.1.159:8001 \
|
|
18
|
+
# --livekit-url ws://100.64.1.5:7880
|
|
19
|
+
#
|
|
20
|
+
# Subsequent runs (token is in device.json):
|
|
21
|
+
# python client.py --scheduler-url http://100.64.1.5:8080
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from motor_bridge import MotorBridge
|
|
37
|
+
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=logging.INFO,
|
|
40
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
41
|
+
)
|
|
42
|
+
log = logging.getLogger("robopark-pi")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PiClient:
|
|
46
|
+
def __init__(self, args: argparse.Namespace):
|
|
47
|
+
self.scheduler_url = args.scheduler_url.rstrip("/")
|
|
48
|
+
self.state_file = Path(args.state_file)
|
|
49
|
+
self.enrollment_token = args.enrollment_token
|
|
50
|
+
self.name = args.name
|
|
51
|
+
self.tailscale_ip = args.tailscale_ip
|
|
52
|
+
self.lan_ip = args.lan_ip
|
|
53
|
+
self.motor_server_url = args.motor_server_url
|
|
54
|
+
self.livekit_url = args.livekit_url
|
|
55
|
+
self.character_id = args.character_id
|
|
56
|
+
self.heartbeat_interval = args.heartbeat_interval
|
|
57
|
+
self.config_poll_interval = args.config_poll_interval
|
|
58
|
+
self.join_room = args.join_room
|
|
59
|
+
self.room_name = args.room_name
|
|
60
|
+
|
|
61
|
+
self.device_id: Optional[str] = None
|
|
62
|
+
self.device_token: Optional[str] = None
|
|
63
|
+
self.production_mode: bool = False
|
|
64
|
+
self.motor_bridge = MotorBridge(self.motor_server_url, dry_run=args.dry_run_motors)
|
|
65
|
+
|
|
66
|
+
self._stop = asyncio.Event()
|
|
67
|
+
self._lk_task: Optional[asyncio.Task] = None
|
|
68
|
+
|
|
69
|
+
# ---- credential persistence ----------------------------------------
|
|
70
|
+
|
|
71
|
+
def _load_state(self) -> bool:
|
|
72
|
+
if not self.state_file.exists():
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(self.state_file.read_text())
|
|
76
|
+
self.device_id = data.get("device_id")
|
|
77
|
+
self.device_token = data.get("device_token")
|
|
78
|
+
# allow CLI overrides to backfill missing fields from a stale state file
|
|
79
|
+
if not self.tailscale_ip: self.tailscale_ip = data.get("tailscale_ip")
|
|
80
|
+
if not self.lan_ip: self.lan_ip = data.get("lan_ip")
|
|
81
|
+
if not self.motor_server_url: self.motor_server_url = data.get("motor_server_url")
|
|
82
|
+
if not self.livekit_url: self.livekit_url = data.get("livekit_url")
|
|
83
|
+
if not self.character_id: self.character_id = data.get("character_id")
|
|
84
|
+
if not self.name: self.name = data.get("name")
|
|
85
|
+
return bool(self.device_id and self.device_token)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
log.warning(f"Could not load state file {self.state_file}: {e}")
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def _save_state(self):
|
|
91
|
+
data = {
|
|
92
|
+
"device_id": self.device_id,
|
|
93
|
+
"device_token": self.device_token,
|
|
94
|
+
"name": self.name,
|
|
95
|
+
"tailscale_ip": self.tailscale_ip,
|
|
96
|
+
"lan_ip": self.lan_ip,
|
|
97
|
+
"motor_server_url": self.motor_server_url,
|
|
98
|
+
"livekit_url": self.livekit_url,
|
|
99
|
+
"character_id": self.character_id,
|
|
100
|
+
"saved_at": time.time(),
|
|
101
|
+
}
|
|
102
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
self.state_file.write_text(json.dumps(data, indent=2))
|
|
104
|
+
try:
|
|
105
|
+
os.chmod(self.state_file, 0o600)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
log.info(f"Saved credentials to {self.state_file}")
|
|
109
|
+
|
|
110
|
+
# ---- scheduler API --------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async def enroll(self):
|
|
113
|
+
body = {
|
|
114
|
+
"enrollment_token": self.enrollment_token,
|
|
115
|
+
"name": self.name,
|
|
116
|
+
"tailscale_ip": self.tailscale_ip,
|
|
117
|
+
"lan_ip": self.lan_ip,
|
|
118
|
+
"motor_server_url": self.motor_server_url,
|
|
119
|
+
"livekit_url": self.livekit_url,
|
|
120
|
+
"character_id": self.character_id,
|
|
121
|
+
}
|
|
122
|
+
async with httpx.AsyncClient(timeout=15.0) as c:
|
|
123
|
+
r = await c.post(f"{self.scheduler_url}/api/devices/enroll", json=body)
|
|
124
|
+
if r.status_code != 200:
|
|
125
|
+
raise RuntimeError(f"Enroll failed ({r.status_code}): {r.text}")
|
|
126
|
+
data = r.json()
|
|
127
|
+
self.device_id = data["device_id"]
|
|
128
|
+
self.device_token = data["device_token"]
|
|
129
|
+
log.info(f"Enrolled as device_id={self.device_id}")
|
|
130
|
+
self._save_state()
|
|
131
|
+
|
|
132
|
+
async def heartbeat(self):
|
|
133
|
+
body = {"status": "online", "ip": self.tailscale_ip or self.lan_ip}
|
|
134
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
135
|
+
r = await c.post(
|
|
136
|
+
f"{self.scheduler_url}/api/devices/{self.device_id}/heartbeat",
|
|
137
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
138
|
+
json=body,
|
|
139
|
+
)
|
|
140
|
+
if r.status_code == 401:
|
|
141
|
+
log.error("Device token rejected; re-enrollment required")
|
|
142
|
+
# wipe creds so next loop tries to enroll again
|
|
143
|
+
self.device_token = None
|
|
144
|
+
if self.state_file.exists():
|
|
145
|
+
self.state_file.unlink()
|
|
146
|
+
return None
|
|
147
|
+
r.raise_for_status()
|
|
148
|
+
return r.json()
|
|
149
|
+
|
|
150
|
+
async def fetch_config(self) -> dict:
|
|
151
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
152
|
+
r = await c.get(
|
|
153
|
+
f"{self.scheduler_url}/api/devices/{self.device_id}/config",
|
|
154
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
155
|
+
)
|
|
156
|
+
r.raise_for_status()
|
|
157
|
+
return r.json()
|
|
158
|
+
|
|
159
|
+
# ---- LiveKit (optional) ---------------------------------------------
|
|
160
|
+
|
|
161
|
+
async def livekit_loop(self):
|
|
162
|
+
"""Join a LiveKit room, publish mic + cam, subscribe to data channel.
|
|
163
|
+
Skips silently if the `livekit` SDK is not installed."""
|
|
164
|
+
try:
|
|
165
|
+
from livekit_bridge import run_livekit # local module
|
|
166
|
+
except ImportError:
|
|
167
|
+
log.warning(
|
|
168
|
+
"livekit_bridge module not available (livekit SDK missing). "
|
|
169
|
+
"Heartbeat + motor bridge will still work; install the `livekit` "
|
|
170
|
+
"and `livekit-api` packages on the Pi to enable rooms."
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Default room: robopark-<device_id>. Allow override via --room-name.
|
|
175
|
+
room = (self.room_name if self.room_name and not self.room_name.startswith("robopark-pi-standby")
|
|
176
|
+
else f"robopark-{self.device_id}")
|
|
177
|
+
# LiveKit URL: prefer the one the scheduler told us about, fall back to CLI.
|
|
178
|
+
lk_url = self.livekit_url or None
|
|
179
|
+
|
|
180
|
+
await run_livekit(
|
|
181
|
+
livekit_url=lk_url,
|
|
182
|
+
room_name=room,
|
|
183
|
+
identity=f"pi:{self.device_id}",
|
|
184
|
+
on_motor_command=self.motor_bridge.handle_command,
|
|
185
|
+
stop_event=self._stop,
|
|
186
|
+
production_mode_provider=lambda: self.production_mode,
|
|
187
|
+
scheduler_url=self.scheduler_url,
|
|
188
|
+
device_id=self.device_id,
|
|
189
|
+
device_token=self.device_token,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ---- main loop ------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async def run(self):
|
|
195
|
+
# 1. credentials
|
|
196
|
+
if not self._load_state():
|
|
197
|
+
if not self.enrollment_token:
|
|
198
|
+
log.error(
|
|
199
|
+
"No saved credentials and no --enrollment-token. "
|
|
200
|
+
"First boot requires --enrollment-token from the scheduler."
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
await self.enroll()
|
|
204
|
+
|
|
205
|
+
log.info(f"Device {self.device_id} starting main loop")
|
|
206
|
+
# 2. start LiveKit worker in background (no-op if SDK missing)
|
|
207
|
+
if self.join_room:
|
|
208
|
+
self._lk_task = asyncio.create_task(self.livekit_loop())
|
|
209
|
+
hb_failures = 0
|
|
210
|
+
try:
|
|
211
|
+
while not self._stop.is_set():
|
|
212
|
+
try:
|
|
213
|
+
hb = await self.heartbeat()
|
|
214
|
+
hb_failures = 0
|
|
215
|
+
if hb:
|
|
216
|
+
self.production_mode = bool(hb.get("production_mode", False))
|
|
217
|
+
if self.production_mode and self._lk_task is None and self.join_room:
|
|
218
|
+
log.info("Production mode ON -> joining LiveKit room")
|
|
219
|
+
self._lk_task = asyncio.create_task(self.livekit_loop())
|
|
220
|
+
elif not self.production_mode and self._lk_task is not None:
|
|
221
|
+
log.info("Production mode OFF -> leaving LiveKit room")
|
|
222
|
+
self._lk_task.cancel()
|
|
223
|
+
try:
|
|
224
|
+
await self._lk_task
|
|
225
|
+
except (asyncio.CancelledError, Exception):
|
|
226
|
+
pass
|
|
227
|
+
self._lk_task = None
|
|
228
|
+
except Exception as e:
|
|
229
|
+
hb_failures += 1
|
|
230
|
+
log.warning(f"Heartbeat error ({hb_failures}): {e}")
|
|
231
|
+
|
|
232
|
+
# periodic config poll (covers cases where production_mode flips but hb is slow)
|
|
233
|
+
try:
|
|
234
|
+
if int(time.time()) % int(self.config_poll_interval) < int(self.heartbeat_interval):
|
|
235
|
+
cfg = await self.fetch_config()
|
|
236
|
+
self.production_mode = bool(cfg.get("production_mode", False))
|
|
237
|
+
if cfg.get("character_id"):
|
|
238
|
+
self.character_id = cfg["character_id"]
|
|
239
|
+
except Exception as e:
|
|
240
|
+
log.debug(f"Config poll error: {e}")
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
await asyncio.wait_for(self._stop.wait(), timeout=self.heartbeat_interval)
|
|
244
|
+
except asyncio.TimeoutError:
|
|
245
|
+
pass
|
|
246
|
+
finally:
|
|
247
|
+
self._stop.set()
|
|
248
|
+
if self._lk_task:
|
|
249
|
+
self._lk_task.cancel()
|
|
250
|
+
try:
|
|
251
|
+
await self._lk_task
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parse_args() -> argparse.Namespace:
|
|
257
|
+
p = argparse.ArgumentParser(description="RoboPark Pi Client")
|
|
258
|
+
p.add_argument("--scheduler-url", required=True, help="e.g. http://100.64.1.5:8080")
|
|
259
|
+
p.add_argument("--enrollment-token", default=None,
|
|
260
|
+
help="One-time enrollment token (first boot only)")
|
|
261
|
+
p.add_argument("--state-file", default="./device.json",
|
|
262
|
+
help="Where to persist device_id + device_token")
|
|
263
|
+
p.add_argument("--name", default=None, help="Device display name")
|
|
264
|
+
p.add_argument("--tailscale-ip", default=None)
|
|
265
|
+
p.add_argument("--lan-ip", default=None)
|
|
266
|
+
p.add_argument("--motor-server-url", default=None,
|
|
267
|
+
help="Base URL of the local RoboVisionAI_PI motor server")
|
|
268
|
+
p.add_argument("--livekit-url", default=None, help="e.g. ws://100.64.1.5:7880")
|
|
269
|
+
p.add_argument("--character-id", default=None, help="Override character assignment")
|
|
270
|
+
p.add_argument("--room-name", default="robopark-pi-standby",
|
|
271
|
+
help="LiveKit room to join")
|
|
272
|
+
p.add_argument("--heartbeat-interval", type=float, default=10.0)
|
|
273
|
+
p.add_argument("--config-poll-interval", type=float, default=30.0)
|
|
274
|
+
p.add_argument("--join-room", action="store_true",
|
|
275
|
+
help="Attempt to join the LiveKit room (requires livekit SDK)")
|
|
276
|
+
p.add_argument("--dry-run-motors", action="store_true",
|
|
277
|
+
help="Log motor commands without calling the motor server")
|
|
278
|
+
return p.parse_args()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _amain():
|
|
282
|
+
args = parse_args()
|
|
283
|
+
client = PiClient(args)
|
|
284
|
+
loop = asyncio.get_running_loop()
|
|
285
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
286
|
+
try:
|
|
287
|
+
loop.add_signal_handler(sig, client._stop.set)
|
|
288
|
+
except NotImplementedError:
|
|
289
|
+
pass # Windows
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
await client.run()
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main():
|
|
298
|
+
try:
|
|
299
|
+
asyncio.run(_amain())
|
|
300
|
+
except KeyboardInterrupt:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install RoboPark Pi Client on a Raspberry Pi
|
|
3
|
+
# Usage:
|
|
4
|
+
# sudo ./install.sh --scheduler-url http://100.64.1.5:8080 \
|
|
5
|
+
# --enrollment-token <TOKEN>
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCHEDULER_URL=""
|
|
9
|
+
ENROLLMENT_TOKEN=""
|
|
10
|
+
|
|
11
|
+
while [[ $# -gt 0 ]]; do
|
|
12
|
+
case "$1" in
|
|
13
|
+
--scheduler-url) SCHEDULER_URL="$2"; shift 2 ;;
|
|
14
|
+
--enrollment-token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
|
|
15
|
+
*) echo "Unknown arg: $1"; exit 1 ;;
|
|
16
|
+
esac
|
|
17
|
+
done
|
|
18
|
+
|
|
19
|
+
if [[ -z "$SCHEDULER_URL" || -z "$ENROLLMENT_TOKEN" ]]; then
|
|
20
|
+
echo "Usage: $0 --scheduler-url URL --enrollment-token TOKEN"
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
id "robopark" 2>/dev/null || useradd -r -s /bin/false robopark
|
|
25
|
+
install -d -m 755 -o robopark -g robopark /opt/robopark-pi-client
|
|
26
|
+
install -d -m 700 -o robopark -g robopark /etc/robopark
|
|
27
|
+
cp -r ./* /opt/robopark-pi-client/
|
|
28
|
+
chown -R robopark:robopark /opt/robopark-pi-client
|
|
29
|
+
|
|
30
|
+
sudo -u robopark python3 -m venv /opt/robopark-pi-client/.venv
|
|
31
|
+
/opt/robopark-pi-client/.venv/bin/pip install --upgrade pip
|
|
32
|
+
/opt/robopark-pi-client/.venv/bin/pip install -r /opt/robopark-pi-client/requirements.txt
|
|
33
|
+
# On Pi you typically want the full media stack:
|
|
34
|
+
/opt/robopark-pi-client/.venv/bin/pip install livekit livekit-api sounddevice numpy opencv-python av
|
|
35
|
+
|
|
36
|
+
cp /opt/robopark-pi-client/robopark-pi-client.service /etc/systemd/system/
|
|
37
|
+
systemctl daemon-reload
|
|
38
|
+
systemctl enable --now robopark-pi-client.service
|
|
39
|
+
|
|
40
|
+
echo "First-boot enrollment will run as part of the service start."
|
|
41
|
+
echo "Tail logs with: journalctl -u robopark-pi-client -f"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Join a LiveKit conversation from the Pi.
|
|
3
|
+
#
|
|
4
|
+
# Calls the local Pi UI's /api/join endpoint, which itself asks the scheduler
|
|
5
|
+
# for a short-lived LiveKit access token. The script prints the meet URL
|
|
6
|
+
# AND (if --open) opens it in the default browser on the Pi.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ./join_convo.sh
|
|
10
|
+
# ./join_convo.sh --room robopark-pi-test --identity alice --open
|
|
11
|
+
# PI_UI=http://192.168.1.159:8081 ./join_convo.sh --open
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
PI_UI="${PI_UI:-http://127.0.0.1:8081}"
|
|
16
|
+
ROOM="${ROOM:-robopark-pi-test}"
|
|
17
|
+
IDENTITY="${IDENTITY:-}"
|
|
18
|
+
OPEN=0
|
|
19
|
+
|
|
20
|
+
while [[ $# -gt 0 ]]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--room) ROOM="$2"; shift 2 ;;
|
|
23
|
+
--identity) IDENTITY="$2"; shift 2 ;;
|
|
24
|
+
--open) OPEN=1; shift ;;
|
|
25
|
+
--pi-ui) PI_UI="$2"; shift 2 ;;
|
|
26
|
+
-h|--help)
|
|
27
|
+
sed -n '2,12p' "$0"; exit 0 ;;
|
|
28
|
+
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
BODY=$(jq -nc --arg room "$ROOM" --arg identity "$IDENTITY" \
|
|
33
|
+
'{room:$room, identity:$identity}')
|
|
34
|
+
|
|
35
|
+
echo "Requesting token from $PI_UI (room=$ROOM identity=$IDENTITY)..."
|
|
36
|
+
RESP=$(curl -fsS -X POST "$PI_UI/api/join" \
|
|
37
|
+
-H 'Content-Type: application/json' -d "$BODY")
|
|
38
|
+
|
|
39
|
+
URL=$(echo "$RESP" | jq -r '
|
|
40
|
+
"https://meet.livekit.io/custom?livekitUrl=" + (.url | @uri) +
|
|
41
|
+
"&token=" + (.token | @uri)
|
|
42
|
+
')
|
|
43
|
+
|
|
44
|
+
EXPIRES=$(echo "$RESP" | jq -r '.expires_at')
|
|
45
|
+
echo "Token expires: $EXPIRES"
|
|
46
|
+
echo "Meet URL:"
|
|
47
|
+
echo " $URL"
|
|
48
|
+
|
|
49
|
+
if [[ $OPEN -eq 1 ]]; then
|
|
50
|
+
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true
|
|
51
|
+
elif command -v sensible-browser >/dev/null 2>&1; then sensible-browser "$URL" >/dev/null 2>&1 || true
|
|
52
|
+
elif command -v open >/dev/null 2>&1; then open "$URL" >/dev/null 2>&1 || true
|
|
53
|
+
else echo "no browser opener found; copy the URL above" >&2; exit 2; fi
|
|
54
|
+
echo "(opened in browser)"
|
|
55
|
+
fi
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""LiveKit bridge for the RoboPark Pi client.
|
|
2
|
+
|
|
3
|
+
Joins a LiveKit room as the Pi's device, publishes mic + camera, and forwards
|
|
4
|
+
inbound data-channel messages to MotorBridge.handle_command().
|
|
5
|
+
|
|
6
|
+
Token strategy: the Pi doesn't hold LiveKit API credentials. It asks the
|
|
7
|
+
scheduler (POST /api/livekit/token) for a short-lived JWT signed by the
|
|
8
|
+
scheduler. The Pi re-fetches the token whenever it's about to (re)connect.
|
|
9
|
+
|
|
10
|
+
Install on Pi:
|
|
11
|
+
pip install livekit livekit-api sounddevice numpy av opencv-python-headless
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Callable, Awaitable, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("robopark-pi.livekit")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _fetch_livekit_token(
|
|
25
|
+
scheduler_url: str, device_id: str, device_token: str,
|
|
26
|
+
room: str, identity: str, ttl_seconds: int = 3600,
|
|
27
|
+
) -> tuple[str, str]:
|
|
28
|
+
"""Ask the scheduler for a LiveKit access token. Returns (url, token)."""
|
|
29
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
30
|
+
r = await c.post(
|
|
31
|
+
f"{scheduler_url.rstrip('/')}/api/livekit/token",
|
|
32
|
+
json={"room": room, "identity": identity, "name": device_id,
|
|
33
|
+
"can_publish": True, "can_subscribe": True,
|
|
34
|
+
"ttl_seconds": ttl_seconds},
|
|
35
|
+
)
|
|
36
|
+
r.raise_for_status()
|
|
37
|
+
data = r.json()
|
|
38
|
+
return data["url"], data["token"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _connect_with_retry(
|
|
42
|
+
scheduler_url: str, device_id: str, device_token: str,
|
|
43
|
+
livekit_url: Optional[str], room_name: str, identity: str,
|
|
44
|
+
stop_event: asyncio.Event,
|
|
45
|
+
) -> Optional["rtc.Room"]:
|
|
46
|
+
"""Fetch a token and connect. Reconnect on failure until stopped."""
|
|
47
|
+
from livekit import rtc
|
|
48
|
+
backoff = 1.0
|
|
49
|
+
while not stop_event.is_set():
|
|
50
|
+
try:
|
|
51
|
+
url, token = await _fetch_livekit_token(
|
|
52
|
+
scheduler_url, device_id, device_token, room_name, identity,
|
|
53
|
+
)
|
|
54
|
+
url = livekit_url or url # CLI override wins
|
|
55
|
+
room = rtc.Room()
|
|
56
|
+
log.info(f"Connecting to LiveKit {url} room={room_name} as {identity}")
|
|
57
|
+
await room.connect(url, token)
|
|
58
|
+
log.info(f"Connected to room {room_name}")
|
|
59
|
+
return room
|
|
60
|
+
except Exception as e:
|
|
61
|
+
log.warning(f"LiveKit connect failed: {e}; retrying in {backoff:.1f}s")
|
|
62
|
+
try:
|
|
63
|
+
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
|
64
|
+
return None # stopped while waiting
|
|
65
|
+
except asyncio.TimeoutError:
|
|
66
|
+
pass
|
|
67
|
+
backoff = min(backoff * 2, 30.0)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def run_livekit(
|
|
72
|
+
livekit_url: Optional[str],
|
|
73
|
+
room_name: str,
|
|
74
|
+
identity: str,
|
|
75
|
+
on_motor_command: Callable[[str | bytes], Awaitable[str]],
|
|
76
|
+
stop_event: asyncio.Event,
|
|
77
|
+
production_mode_provider: Callable[[], bool],
|
|
78
|
+
scheduler_url: str = "",
|
|
79
|
+
device_id: str = "",
|
|
80
|
+
device_token: str = "",
|
|
81
|
+
):
|
|
82
|
+
"""Join a LiveKit room, publish mic + cam, subscribe to data channel.
|
|
83
|
+
|
|
84
|
+
Reconnects on disconnect, refetching the token each time.
|
|
85
|
+
Stops cleanly when production_mode flips False or stop_event is set.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
from livekit import rtc
|
|
89
|
+
except ImportError as e:
|
|
90
|
+
log.warning(f"LiveKit SDK not available, cannot join room: {e}")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
while not stop_event.is_set():
|
|
94
|
+
if not production_mode_provider():
|
|
95
|
+
log.info("production_mode is OFF; not joining LiveKit")
|
|
96
|
+
try:
|
|
97
|
+
await asyncio.wait_for(stop_event.wait(), timeout=5.0)
|
|
98
|
+
except asyncio.TimeoutError:
|
|
99
|
+
continue
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
room = await _connect_with_retry(
|
|
103
|
+
scheduler_url, device_id, device_token,
|
|
104
|
+
livekit_url, room_name, identity, stop_event,
|
|
105
|
+
)
|
|
106
|
+
if room is None:
|
|
107
|
+
return # stop requested
|
|
108
|
+
|
|
109
|
+
@room.on("data_received")
|
|
110
|
+
def _on_data(packet):
|
|
111
|
+
asyncio.create_task(on_motor_command(packet.data))
|
|
112
|
+
|
|
113
|
+
@room.on("disconnected")
|
|
114
|
+
def _on_disc():
|
|
115
|
+
log.warning("LiveKit disconnected")
|
|
116
|
+
|
|
117
|
+
# --- Audio output: play remote participants' TTS through speakers ---
|
|
118
|
+
async def _play_remote_audio():
|
|
119
|
+
"""Subscribe to audio tracks from other participants (agent TTS)
|
|
120
|
+
and play them through the Pi's default audio output device."""
|
|
121
|
+
try:
|
|
122
|
+
import sounddevice as sd
|
|
123
|
+
import numpy as np
|
|
124
|
+
except Exception as e:
|
|
125
|
+
log.warning(f"sounddevice/numpy unavailable for playback: {e}")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
OUT_RATE = 48000 # sounddevice default output rate
|
|
129
|
+
_active_streams: dict = {} # sid -> sd.OutputStream
|
|
130
|
+
|
|
131
|
+
def _on_track(track, publication, participant):
|
|
132
|
+
if track.kind != rtc.TrackKind.KIND_AUDIO:
|
|
133
|
+
return
|
|
134
|
+
if participant.identity == room.local_participant.identity:
|
|
135
|
+
return # don't play our own mic
|
|
136
|
+
sid = publication.sid
|
|
137
|
+
log.info(f"audio out: subscribing to {participant.identity} / {sid}")
|
|
138
|
+
|
|
139
|
+
async def _stream_audio():
|
|
140
|
+
try:
|
|
141
|
+
stream = rtc.AudioStream(track=track)
|
|
142
|
+
# Open output stream at 48kHz mono (Pi speaker)
|
|
143
|
+
out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0)
|
|
144
|
+
out.start()
|
|
145
|
+
_active_streams[sid] = out
|
|
146
|
+
|
|
147
|
+
async for frame in stream:
|
|
148
|
+
# SDK v1.1.12 yields AudioFrameEvent; v1.1.13 yields AudioFrame
|
|
149
|
+
af = frame.frame if hasattr(frame, 'frame') else frame
|
|
150
|
+
in_rate = af.sample_rate
|
|
151
|
+
in_data = np.frombuffer(af.data, dtype=np.int16)
|
|
152
|
+
if in_rate == OUT_RATE:
|
|
153
|
+
out_data = in_data
|
|
154
|
+
else:
|
|
155
|
+
# Simple linear resample
|
|
156
|
+
ratio = OUT_RATE / in_rate
|
|
157
|
+
n_out = int(len(in_data) * ratio)
|
|
158
|
+
indices = np.linspace(0, len(in_data) - 1, n_out)
|
|
159
|
+
out_data = np.interp(indices, np.arange(len(in_data)), in_data.astype(np.float64)).astype(np.int16)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
out.write(out_data)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
log.debug(f"audio out write error: {e}")
|
|
165
|
+
break
|
|
166
|
+
except Exception as e:
|
|
167
|
+
log.warning(f"audio out stream error: {e}")
|
|
168
|
+
finally:
|
|
169
|
+
try:
|
|
170
|
+
if sid in _active_streams:
|
|
171
|
+
_active_streams[sid].stop()
|
|
172
|
+
del _active_streams[sid]
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
log.info(f"audio out: stopped {sid}")
|
|
176
|
+
|
|
177
|
+
asyncio.create_task(_stream_audio())
|
|
178
|
+
|
|
179
|
+
@room.on("track_subscribed")
|
|
180
|
+
def _on_track_subscribed(track, publication, participant):
|
|
181
|
+
_on_track(track, publication, participant)
|
|
182
|
+
|
|
183
|
+
# Also handle tracks already present (agent joined before us)
|
|
184
|
+
for p in room.remote_participants.values():
|
|
185
|
+
for pub in p.track_publications.values():
|
|
186
|
+
if pub.track and pub.track.kind == rtc.TrackKind.KIND_AUDIO:
|
|
187
|
+
_on_track(pub.track, pub, p)
|
|
188
|
+
|
|
189
|
+
# Keep alive until disconnect
|
|
190
|
+
while not stop_event.is_set() and room.connection_state == rtc.ConnectionState.CONN_CONNECTED:
|
|
191
|
+
await asyncio.sleep(0.5)
|
|
192
|
+
|
|
193
|
+
# Cleanup
|
|
194
|
+
for s in _active_streams.values():
|
|
195
|
+
try: s.stop()
|
|
196
|
+
except Exception: pass
|
|
197
|
+
_active_streams.clear()
|
|
198
|
+
|
|
199
|
+
# Publish tracks
|
|
200
|
+
async def _publish_mic():
|
|
201
|
+
try:
|
|
202
|
+
import sounddevice as sd
|
|
203
|
+
except Exception as e:
|
|
204
|
+
log.warning(f"sounddevice unavailable, skipping mic: {e}")
|
|
205
|
+
return
|
|
206
|
+
# The USB mic is ALSA device index 1 (1 in, 0 out). Default is output-only.
|
|
207
|
+
MIC_DEVICE = 1
|
|
208
|
+
try:
|
|
209
|
+
log.info(f"mic: opening device {MIC_DEVICE} ({sd.query_devices()[MIC_DEVICE]['name']})")
|
|
210
|
+
except Exception:
|
|
211
|
+
log.warning(f"mic: device {MIC_DEVICE} not found, trying default")
|
|
212
|
+
MIC_DEVICE = None
|
|
213
|
+
try:
|
|
214
|
+
source = rtc.AudioSource(sample_rate=16000, num_channels=1)
|
|
215
|
+
track = rtc.LocalAudioTrack.create_audio_track("mic", source)
|
|
216
|
+
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
|
|
217
|
+
await room.local_participant.publish_track(track, opts)
|
|
218
|
+
log.info("mic published (source=MICROPHONE, 16kHz)")
|
|
219
|
+
loop = asyncio.get_event_loop()
|
|
220
|
+
def _cb(indata, frames, time_info, status):
|
|
221
|
+
if status: log.debug(f"mic status: {status}")
|
|
222
|
+
try:
|
|
223
|
+
frame = rtc.AudioFrame(
|
|
224
|
+
data=indata.tobytes(),
|
|
225
|
+
sample_rate=16000,
|
|
226
|
+
num_channels=1,
|
|
227
|
+
samples_per_channel=frames,
|
|
228
|
+
)
|
|
229
|
+
asyncio.run_coroutine_threadsafe(
|
|
230
|
+
source.capture_frame(frame), loop)
|
|
231
|
+
except Exception: pass
|
|
232
|
+
with sd.InputStream(device=MIC_DEVICE, samplerate=16000, channels=1, dtype="int16", callback=_cb):
|
|
233
|
+
while not stop_event.is_set() and room.connection_state == rtc.ConnectionState.CONN_CONNECTED:
|
|
234
|
+
await asyncio.sleep(0.1)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
log.warning(f"mic publish error: {e}")
|
|
237
|
+
|
|
238
|
+
async def _publish_cam():
|
|
239
|
+
try:
|
|
240
|
+
import cv2
|
|
241
|
+
except Exception as e:
|
|
242
|
+
log.warning(f"opencv unavailable, skipping camera: {e}")
|
|
243
|
+
return
|
|
244
|
+
cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
|
|
245
|
+
if not cap.isOpened():
|
|
246
|
+
log.warning("camera /dev/video0 not available")
|
|
247
|
+
return
|
|
248
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
249
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
250
|
+
cap.set(cv2.CAP_PROP_FPS, 15)
|
|
251
|
+
try:
|
|
252
|
+
source = rtc.VideoSource(width=640, height=480)
|
|
253
|
+
track = rtc.LocalVideoTrack.create_video_track("cam", source)
|
|
254
|
+
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
|
|
255
|
+
await room.local_participant.publish_track(track, opts)
|
|
256
|
+
log.info("camera published (source=CAMERA)")
|
|
257
|
+
while not stop_event.is_set() and room.connection_state == rtc.ConnectionState.CONN_CONNECTED:
|
|
258
|
+
ok, frame = cap.read()
|
|
259
|
+
if not ok: await asyncio.sleep(0.1); continue
|
|
260
|
+
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
261
|
+
vf = rtc.VideoFrame(width=640, height=480, type=rtc.VideoBufferType.RGB24, data=rgb.tobytes())
|
|
262
|
+
source.capture_frame(vf)
|
|
263
|
+
await asyncio.sleep(1/15)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
log.warning(f"camera publish error: {e}")
|
|
266
|
+
finally:
|
|
267
|
+
cap.release()
|
|
268
|
+
|
|
269
|
+
mic_task = asyncio.create_task(_publish_mic())
|
|
270
|
+
cam_task = asyncio.create_task(_publish_cam())
|
|
271
|
+
audio_out_task = asyncio.create_task(_play_remote_audio())
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
while not stop_event.is_set():
|
|
275
|
+
if not production_mode_provider():
|
|
276
|
+
log.info("production_mode flipped OFF; leaving room")
|
|
277
|
+
break
|
|
278
|
+
if room.connection_state != rtc.ConnectionState.CONN_CONNECTED:
|
|
279
|
+
log.info("room not connected; will reconnect")
|
|
280
|
+
break
|
|
281
|
+
await asyncio.sleep(1)
|
|
282
|
+
finally:
|
|
283
|
+
mic_task.cancel(); cam_task.cancel(); audio_out_task.cancel()
|
|
284
|
+
for t in (mic_task, cam_task, audio_out_task):
|
|
285
|
+
try: await t
|
|
286
|
+
except Exception: pass
|
|
287
|
+
try: await room.disconnect()
|
|
288
|
+
except Exception: pass
|
|
289
|
+
log.info("left room, will loop to reconnect")
|