infinicode 2.8.7 → 2.8.9
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/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/enroll.d.ts +7 -0
- package/dist/robopark/enroll.js +101 -0
- package/dist/robopark/preview-agent-launcher.d.ts +14 -0
- package/dist/robopark/preview-agent-launcher.js +102 -0
- package/dist/robopark/probe.d.ts +7 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/serve.js +23 -3
- package/dist/robopark/setup-livekit.d.ts +8 -0
- package/dist/robopark/setup-livekit.js +233 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +32 -0
- package/dist/robopark/verify.d.ts +6 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark-cli.js +68 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
- package/packages/robopark/scheduler/main.py +276 -25
- package/packages/robopark/scheduler/preview_agent.py +639 -0
- package/packages/robopark/scheduler/requirements.txt +4 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
RoboPark Preview Agent — runs on each robot/satellite Pi.
|
|
4
|
+
|
|
5
|
+
Polls the scheduler for an active operator preview request. When active,
|
|
6
|
+
opens the selected camera + microphone and publishes them to the returned
|
|
7
|
+
LiveKit room. When the preview expires or is stopped, the agent leaves the
|
|
8
|
+
room and releases the hardware.
|
|
9
|
+
|
|
10
|
+
Configuration (env / ~/.robopark/preview_agent.json):
|
|
11
|
+
SCHEDULER_URL base URL of the RoboPark scheduler
|
|
12
|
+
ROBOT_ID robot identity in the scheduler (defaults to hostname)
|
|
13
|
+
DEVICE_TOKEN long-lived device token (after enrollment)
|
|
14
|
+
ENROLLMENT_TOKEN one-time enrollment token (device token will be saved)
|
|
15
|
+
VIDEO_DEVICE camera path/index/name (e.g. /dev/video0, 0, "PiCamera")
|
|
16
|
+
AUDIO_DEVICE microphone name/index or "default"
|
|
17
|
+
VIDEO_WIDTH / HEIGHT capture resolution (default 640x480)
|
|
18
|
+
VIDEO_FPS capture fps (default 15)
|
|
19
|
+
POLL_INTERVAL seconds between scheduler polls (default 3)
|
|
20
|
+
HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import signal
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("robopark.preview_agent")
|
|
39
|
+
|
|
40
|
+
CONFIG_DIR = Path.home() / ".robopark"
|
|
41
|
+
CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
|
|
42
|
+
TOKEN_FILE = CONFIG_DIR / "device_token"
|
|
43
|
+
|
|
44
|
+
DEFAULT_VIDEO_WIDTH = 640
|
|
45
|
+
DEFAULT_VIDEO_HEIGHT = 480
|
|
46
|
+
DEFAULT_FPS = 15
|
|
47
|
+
DEFAULT_POLL_INTERVAL = 3.0
|
|
48
|
+
DEFAULT_HEARTBEAT_INTERVAL = 30.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_config() -> dict:
|
|
52
|
+
cfg: dict = {}
|
|
53
|
+
if CONFIG_FILE.exists():
|
|
54
|
+
try:
|
|
55
|
+
cfg = json.loads(CONFIG_FILE.read_text(encoding="utf8"))
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"failed to read {CONFIG_FILE}: {e}")
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_config(cfg: dict) -> None:
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_token() -> Optional[str]:
|
|
67
|
+
if TOKEN_FILE.exists():
|
|
68
|
+
return TOKEN_FILE.read_text(encoding="utf8").strip() or None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_token(token: str) -> None:
|
|
73
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
75
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
|
|
79
|
+
"""Enroll this Pi with the scheduler and return the device token."""
|
|
80
|
+
import socket
|
|
81
|
+
payload = {
|
|
82
|
+
"enrollment_token": enrollment_token,
|
|
83
|
+
"name": robot_id,
|
|
84
|
+
"lan_ip": _get_lan_ip(),
|
|
85
|
+
}
|
|
86
|
+
async with httpx.AsyncClient() as client:
|
|
87
|
+
r = await client.post(
|
|
88
|
+
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
89
|
+
json=payload,
|
|
90
|
+
timeout=30.0,
|
|
91
|
+
)
|
|
92
|
+
r.raise_for_status()
|
|
93
|
+
data = r.json()
|
|
94
|
+
device_token = data.get("device_token")
|
|
95
|
+
if not device_token:
|
|
96
|
+
raise RuntimeError("enrollment did not return a device_token")
|
|
97
|
+
_save_token(device_token)
|
|
98
|
+
cfg = _load_config()
|
|
99
|
+
cfg["device_id"] = data.get("device_id")
|
|
100
|
+
cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
|
|
101
|
+
_save_config(cfg)
|
|
102
|
+
logger.info(f"enrolled as device {data.get('device_id')}")
|
|
103
|
+
return device_token
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_lan_ip() -> Optional[str]:
|
|
107
|
+
try:
|
|
108
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
109
|
+
s.settimeout(0.5)
|
|
110
|
+
s.connect(("10.255.255.255", 1))
|
|
111
|
+
ip = s.getsockname()[0]
|
|
112
|
+
s.close()
|
|
113
|
+
return ip
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
|
|
119
|
+
try:
|
|
120
|
+
async with httpx.AsyncClient() as client:
|
|
121
|
+
await client.post(
|
|
122
|
+
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
123
|
+
json={"status": "online"},
|
|
124
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
125
|
+
timeout=10.0,
|
|
126
|
+
)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.debug(f"heartbeat failed: {e}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PreviewState:
|
|
133
|
+
active: bool = False
|
|
134
|
+
url: Optional[str] = None
|
|
135
|
+
token: Optional[str] = None
|
|
136
|
+
room: Optional[str] = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PreviewAgent:
|
|
140
|
+
def __init__(self, cfg: dict):
|
|
141
|
+
self.scheduler_url = cfg.get("scheduler_url", os.getenv("SCHEDULER_URL", "http://localhost:8080"))
|
|
142
|
+
self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
|
|
143
|
+
self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
|
|
144
|
+
self.device_id: Optional[str] = cfg.get("device_id")
|
|
145
|
+
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
146
|
+
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
147
|
+
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
148
|
+
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
149
|
+
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
150
|
+
self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
151
|
+
self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
152
|
+
|
|
153
|
+
self._shutdown = asyncio.Event()
|
|
154
|
+
self._task: Optional[asyncio.Task] = None
|
|
155
|
+
self._session: Optional[httpx.AsyncClient] = None
|
|
156
|
+
self._current_state: PreviewState = PreviewState()
|
|
157
|
+
self._publisher: Optional["LiveKitPublisher"] = None
|
|
158
|
+
|
|
159
|
+
async def run(self) -> None:
|
|
160
|
+
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or cfg.get("enrollment_token")
|
|
161
|
+
if not self.device_token and enrollment_token:
|
|
162
|
+
self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
|
|
163
|
+
|
|
164
|
+
if not self.device_token:
|
|
165
|
+
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
if not self.device_id:
|
|
169
|
+
await self._resolve_device_id()
|
|
170
|
+
|
|
171
|
+
self._session = httpx.AsyncClient()
|
|
172
|
+
|
|
173
|
+
tasks = [
|
|
174
|
+
asyncio.create_task(self._poll_loop()),
|
|
175
|
+
asyncio.create_task(self._heartbeat_loop()),
|
|
176
|
+
]
|
|
177
|
+
await self._shutdown.wait()
|
|
178
|
+
for t in tasks:
|
|
179
|
+
t.cancel()
|
|
180
|
+
try:
|
|
181
|
+
await t
|
|
182
|
+
except asyncio.CancelledError:
|
|
183
|
+
pass
|
|
184
|
+
await self._stop_publisher()
|
|
185
|
+
await self._session.aclose()
|
|
186
|
+
|
|
187
|
+
async def _resolve_device_id(self) -> None:
|
|
188
|
+
"""Look up our device_id from the scheduler using the token."""
|
|
189
|
+
if not self._session or not self.device_token:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
r = await self._session.get(
|
|
193
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices",
|
|
194
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
195
|
+
timeout=10.0,
|
|
196
|
+
)
|
|
197
|
+
r.raise_for_status()
|
|
198
|
+
for d in r.json():
|
|
199
|
+
if d.get("name") == self.robot_id:
|
|
200
|
+
self.device_id = d.get("id")
|
|
201
|
+
cfg = _load_config()
|
|
202
|
+
cfg["device_id"] = self.device_id
|
|
203
|
+
_save_config(cfg)
|
|
204
|
+
return
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.warning(f"could not resolve device_id: {e}")
|
|
207
|
+
|
|
208
|
+
async def _poll_loop(self) -> None:
|
|
209
|
+
while not self._shutdown.is_set():
|
|
210
|
+
try:
|
|
211
|
+
state = await self._fetch_preview_state()
|
|
212
|
+
await self._apply_state(state)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"poll error: {e}")
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.wait_for(self._shutdown.wait(), timeout=self.poll_interval)
|
|
217
|
+
except asyncio.TimeoutError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
async def _heartbeat_loop(self) -> None:
|
|
221
|
+
while not self._shutdown.is_set():
|
|
222
|
+
if self.device_id and self.device_token:
|
|
223
|
+
await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
|
|
224
|
+
try:
|
|
225
|
+
await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
|
|
226
|
+
except asyncio.TimeoutError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _fetch_preview_state(self) -> PreviewState:
|
|
230
|
+
if not self._session or not self.device_token:
|
|
231
|
+
return PreviewState()
|
|
232
|
+
r = await self._session.get(
|
|
233
|
+
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.robot_id}/preview/agent",
|
|
234
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
235
|
+
timeout=10.0,
|
|
236
|
+
)
|
|
237
|
+
r.raise_for_status()
|
|
238
|
+
data = r.json()
|
|
239
|
+
if not data.get("active"):
|
|
240
|
+
return PreviewState()
|
|
241
|
+
return PreviewState(
|
|
242
|
+
active=True,
|
|
243
|
+
url=data.get("url"),
|
|
244
|
+
token=data.get("token"),
|
|
245
|
+
room=data.get("room"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def _apply_state(self, state: PreviewState) -> None:
|
|
249
|
+
same = (
|
|
250
|
+
state.active == self._current_state.active
|
|
251
|
+
and state.room == self._current_state.room
|
|
252
|
+
and state.token == self._current_state.token
|
|
253
|
+
and state.url == self._current_state.url
|
|
254
|
+
)
|
|
255
|
+
if same:
|
|
256
|
+
return
|
|
257
|
+
self._current_state = state
|
|
258
|
+
if not state.active:
|
|
259
|
+
await self._stop_publisher()
|
|
260
|
+
return
|
|
261
|
+
await self._start_publisher(state)
|
|
262
|
+
|
|
263
|
+
async def _start_publisher(self, state: PreviewState) -> None:
|
|
264
|
+
await self._stop_publisher()
|
|
265
|
+
if not state.url or not state.token or not state.room:
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
pub = LiveKitPublisher(state.url, state.token, state.room, self)
|
|
269
|
+
await pub.start()
|
|
270
|
+
self._publisher = pub
|
|
271
|
+
logger.info(f"joined preview room {state.room}")
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(f"failed to start publisher: {e}")
|
|
274
|
+
|
|
275
|
+
async def _stop_publisher(self) -> None:
|
|
276
|
+
if self._publisher:
|
|
277
|
+
try:
|
|
278
|
+
await self._publisher.stop()
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.warning(f"publisher stop error: {e}")
|
|
281
|
+
self._publisher = None
|
|
282
|
+
|
|
283
|
+
def shutdown(self) -> None:
|
|
284
|
+
self._shutdown.set()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class LiveKitPublisher:
|
|
288
|
+
"""Encapsulates LiveKit room connection, camera capture and mic capture."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, url: str, token: str, room_name: str, agent: PreviewAgent):
|
|
291
|
+
from livekit import rtc
|
|
292
|
+
self.url = url
|
|
293
|
+
self.token = token
|
|
294
|
+
self.room_name = room_name
|
|
295
|
+
self.agent = agent
|
|
296
|
+
self.rtc = rtc
|
|
297
|
+
self.room: Optional[rtc.Room] = None
|
|
298
|
+
self.video_source: Optional[rtc.VideoSource] = None
|
|
299
|
+
self.video_track: Optional[rtc.LocalVideoTrack] = None
|
|
300
|
+
self.audio_source: Optional[rtc.AudioSource] = None
|
|
301
|
+
self.audio_track: Optional[rtc.LocalAudioTrack] = None
|
|
302
|
+
self._stop_event = asyncio.Event()
|
|
303
|
+
self._tasks: list[asyncio.Task] = []
|
|
304
|
+
self._capture: Optional["VideoCapture"] = None
|
|
305
|
+
|
|
306
|
+
async def start(self) -> None:
|
|
307
|
+
self.room = self.rtc.Room()
|
|
308
|
+
await self.room.connect(self.url, self.token)
|
|
309
|
+
|
|
310
|
+
# Video
|
|
311
|
+
self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
|
|
312
|
+
self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
|
|
313
|
+
vopts = self.rtc.TrackPublishOptions()
|
|
314
|
+
vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
|
|
315
|
+
await self.room.local_participant.publish_track(self.video_track, vopts)
|
|
316
|
+
|
|
317
|
+
# Audio
|
|
318
|
+
self.audio_source = self.rtc.AudioSource(48000, 1)
|
|
319
|
+
self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
|
|
320
|
+
aopts = self.rtc.TrackPublishOptions()
|
|
321
|
+
aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
|
|
322
|
+
await self.room.local_participant.publish_track(self.audio_track, aopts)
|
|
323
|
+
|
|
324
|
+
self._capture = create_video_capture(self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps)
|
|
325
|
+
if self._capture:
|
|
326
|
+
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
327
|
+
if has_audio():
|
|
328
|
+
self._tasks.append(asyncio.create_task(self._audio_loop()))
|
|
329
|
+
|
|
330
|
+
async def stop(self) -> None:
|
|
331
|
+
self._stop_event.set()
|
|
332
|
+
for t in self._tasks:
|
|
333
|
+
t.cancel()
|
|
334
|
+
try:
|
|
335
|
+
await t
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
pass
|
|
338
|
+
self._tasks.clear()
|
|
339
|
+
if self._capture:
|
|
340
|
+
self._capture.stop()
|
|
341
|
+
self._capture = None
|
|
342
|
+
if self.room:
|
|
343
|
+
await self.room.disconnect()
|
|
344
|
+
self.room = None
|
|
345
|
+
|
|
346
|
+
async def _video_loop(self) -> None:
|
|
347
|
+
assert self._capture is not None and self.video_source is not None
|
|
348
|
+
frame_interval = 1.0 / self.agent.fps
|
|
349
|
+
while not self._stop_event.is_set():
|
|
350
|
+
start = time.monotonic()
|
|
351
|
+
frame = self._capture.read()
|
|
352
|
+
if frame is not None:
|
|
353
|
+
self.video_source.capture_frame(frame)
|
|
354
|
+
elapsed = time.monotonic() - start
|
|
355
|
+
sleep_for = frame_interval - elapsed
|
|
356
|
+
if sleep_for > 0:
|
|
357
|
+
try:
|
|
358
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
|
|
359
|
+
except asyncio.TimeoutError:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
async def _audio_loop(self) -> None:
|
|
363
|
+
assert self.audio_source is not None
|
|
364
|
+
mic = create_audio_capture(self.agent.audio_device)
|
|
365
|
+
if mic is None:
|
|
366
|
+
return
|
|
367
|
+
frame_duration = 0.02 # 20ms
|
|
368
|
+
samples_per_frame = int(48000 * frame_duration)
|
|
369
|
+
while not self._stop_event.is_set():
|
|
370
|
+
frame = mic.read(samples_per_frame)
|
|
371
|
+
if frame is not None:
|
|
372
|
+
self.audio_source.capture_frame(frame)
|
|
373
|
+
try:
|
|
374
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=frame_duration)
|
|
375
|
+
except asyncio.TimeoutError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# -----------------------------------------------------------------------------
|
|
380
|
+
# Video capture abstraction: V4L2/USB via OpenCV, or picamera2 on Pi.
|
|
381
|
+
# -----------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
class VideoCapture:
|
|
384
|
+
def read(self) -> Optional["rtc.VideoFrame"]:
|
|
385
|
+
raise NotImplementedError
|
|
386
|
+
|
|
387
|
+
def stop(self) -> None:
|
|
388
|
+
raise NotImplementedError
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class OpencvVideoCapture(VideoCapture):
|
|
392
|
+
def __init__(self, device: int | str, width: int, height: int, fps: int):
|
|
393
|
+
import cv2
|
|
394
|
+
self.cv2 = cv2
|
|
395
|
+
if isinstance(device, str) and device.startswith("/dev/video"):
|
|
396
|
+
device = int(device.replace("/dev/video", ""))
|
|
397
|
+
self.cap = cv2.VideoCapture(device)
|
|
398
|
+
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
399
|
+
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
400
|
+
self.cap.set(cv2.CAP_PROP_FPS, fps)
|
|
401
|
+
self._ensure_frame()
|
|
402
|
+
|
|
403
|
+
def _ensure_frame(self):
|
|
404
|
+
ok, _ = self.cap.read()
|
|
405
|
+
if not ok:
|
|
406
|
+
raise RuntimeError("cannot read from camera")
|
|
407
|
+
|
|
408
|
+
def read(self):
|
|
409
|
+
from livekit import rtc
|
|
410
|
+
ok, bgr = self.cap.read()
|
|
411
|
+
if not ok or bgr is None:
|
|
412
|
+
return None
|
|
413
|
+
return rtc.VideoFrame(
|
|
414
|
+
width=bgr.shape[1],
|
|
415
|
+
height=bgr.shape[0],
|
|
416
|
+
type=rtc.VideoBufferType.BGR,
|
|
417
|
+
data=bgr.tobytes(),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def stop(self):
|
|
421
|
+
self.cap.release()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Picamera2Capture(VideoCapture):
|
|
425
|
+
def __init__(self, width: int, height: int, fps: int):
|
|
426
|
+
from picamera2 import Picamera2
|
|
427
|
+
self.picam = Picamera2()
|
|
428
|
+
config = self.picam.create_video_configuration(
|
|
429
|
+
main={"format": "RGB888", "size": (width, height)},
|
|
430
|
+
controls={"FrameRate": fps},
|
|
431
|
+
)
|
|
432
|
+
self.picam.configure(config)
|
|
433
|
+
self.picam.start()
|
|
434
|
+
self.width = width
|
|
435
|
+
self.height = height
|
|
436
|
+
|
|
437
|
+
def read(self):
|
|
438
|
+
from livekit import rtc
|
|
439
|
+
arr = self.picam.capture_array()
|
|
440
|
+
if arr is None:
|
|
441
|
+
return None
|
|
442
|
+
return rtc.VideoFrame(
|
|
443
|
+
width=self.width,
|
|
444
|
+
height=self.height,
|
|
445
|
+
type=rtc.VideoBufferType.RGB,
|
|
446
|
+
data=arr.tobytes(),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def stop(self):
|
|
450
|
+
try:
|
|
451
|
+
self.picam.stop()
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
|
|
457
|
+
if device.lower() in ("none", "", "false", "null"):
|
|
458
|
+
return None
|
|
459
|
+
try:
|
|
460
|
+
from livekit import rtc
|
|
461
|
+
_ = rtc.VideoSource
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"livekit python sdk not installed: {e}")
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
467
|
+
if device.lower() in ("auto", "default", "first"):
|
|
468
|
+
for i in range(4):
|
|
469
|
+
try:
|
|
470
|
+
cap = OpencvVideoCapture(i, width, height, fps)
|
|
471
|
+
logger.info(f"auto-selected USB camera /dev/video{i}")
|
|
472
|
+
return cap
|
|
473
|
+
except Exception:
|
|
474
|
+
continue
|
|
475
|
+
try:
|
|
476
|
+
cap = Picamera2Capture(width, height, fps)
|
|
477
|
+
logger.info("auto-selected Pi Camera")
|
|
478
|
+
return cap
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
logger.error("no camera found (tried V4L2 and picamera2)")
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
if device.lower() in ("picamera", "picamera2", "pi", "rpi"):
|
|
485
|
+
return Picamera2Capture(width, height, fps)
|
|
486
|
+
|
|
487
|
+
# Treat as V4L2 index or path
|
|
488
|
+
return OpencvVideoCapture(device, width, height, fps)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# -----------------------------------------------------------------------------
|
|
492
|
+
# Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
|
|
493
|
+
# -----------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
class AudioCapture:
|
|
496
|
+
def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
|
|
497
|
+
raise NotImplementedError
|
|
498
|
+
|
|
499
|
+
def stop(self) -> None:
|
|
500
|
+
raise NotImplementedError
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class PyAudioCapture(AudioCapture):
|
|
504
|
+
def __init__(self, device: str | int | None):
|
|
505
|
+
import pyaudio
|
|
506
|
+
self.pa = pyaudio.PyAudio()
|
|
507
|
+
self.device_index = self._resolve_device(device)
|
|
508
|
+
self.buffer = bytearray()
|
|
509
|
+
self.stream = self.pa.open(
|
|
510
|
+
format=pyaudio.paInt16,
|
|
511
|
+
channels=1,
|
|
512
|
+
rate=48000,
|
|
513
|
+
input=True,
|
|
514
|
+
input_device_index=self.device_index,
|
|
515
|
+
frames_per_buffer=960,
|
|
516
|
+
stream_callback=self._callback,
|
|
517
|
+
)
|
|
518
|
+
self.stream.start_stream()
|
|
519
|
+
|
|
520
|
+
def _resolve_device(self, device: str | int | None) -> Optional[int]:
|
|
521
|
+
if device is None or device == "default":
|
|
522
|
+
return None
|
|
523
|
+
if isinstance(device, int):
|
|
524
|
+
return device
|
|
525
|
+
# Try exact name match
|
|
526
|
+
for i in range(self.pa.get_device_count()):
|
|
527
|
+
info = self.pa.get_device_info_by_index(i)
|
|
528
|
+
if info.get("maxInputChannels", 0) > 0 and device.lower() in str(info.get("name", "")).lower():
|
|
529
|
+
return i
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
def _callback(self, in_data, frame_count, time_info, status):
|
|
533
|
+
self.buffer.extend(in_data)
|
|
534
|
+
return (None, pyaudio.paContinue)
|
|
535
|
+
|
|
536
|
+
def read(self, samples_per_frame: int):
|
|
537
|
+
from livekit import rtc
|
|
538
|
+
bytes_needed = samples_per_frame * 2 # int16 mono
|
|
539
|
+
while len(self.buffer) < bytes_needed:
|
|
540
|
+
time.sleep(0.005)
|
|
541
|
+
chunk = bytes(self.buffer[:bytes_needed])
|
|
542
|
+
self.buffer = self.buffer[bytes_needed:]
|
|
543
|
+
return rtc.AudioFrame(
|
|
544
|
+
data=chunk,
|
|
545
|
+
sample_rate=48000,
|
|
546
|
+
num_channels=1,
|
|
547
|
+
samples_per_channel=samples_per_frame,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def stop(self):
|
|
551
|
+
if self.stream:
|
|
552
|
+
self.stream.stop_stream()
|
|
553
|
+
self.stream.close()
|
|
554
|
+
self.pa.terminate()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def has_audio() -> bool:
|
|
558
|
+
try:
|
|
559
|
+
import pyaudio # noqa: F401
|
|
560
|
+
return True
|
|
561
|
+
except Exception:
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
566
|
+
if device.lower() in ("none", "", "false", "null"):
|
|
567
|
+
return None
|
|
568
|
+
try:
|
|
569
|
+
import pyaudio # noqa: F401
|
|
570
|
+
return PyAudioCapture(device if device != "default" else None)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.warning(f"pyaudio not available: {e}")
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _hostname() -> str:
|
|
577
|
+
import socket
|
|
578
|
+
return socket.gethostname().split(".")[0]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main() -> None:
|
|
582
|
+
parser = argparse.ArgumentParser(description="RoboPark preview agent")
|
|
583
|
+
parser.add_argument("--scheduler-url", default=os.getenv("SCHEDULER_URL", "http://localhost:8080"))
|
|
584
|
+
parser.add_argument("--robot-id", default=os.getenv("ROBOT_ID", _hostname()))
|
|
585
|
+
parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
|
|
586
|
+
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
587
|
+
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
588
|
+
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
589
|
+
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
590
|
+
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
591
|
+
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
592
|
+
parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
593
|
+
parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
594
|
+
parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
|
|
595
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
596
|
+
args = parser.parse_args()
|
|
597
|
+
|
|
598
|
+
logging.basicConfig(
|
|
599
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
600
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
cfg = _load_config()
|
|
604
|
+
cfg.update({
|
|
605
|
+
"scheduler_url": args.scheduler_url,
|
|
606
|
+
"robot_id": args.robot_id,
|
|
607
|
+
"video_device": args.video_device,
|
|
608
|
+
"audio_device": args.audio_device,
|
|
609
|
+
"video_width": args.width,
|
|
610
|
+
"video_height": args.height,
|
|
611
|
+
"video_fps": args.fps,
|
|
612
|
+
"poll_interval": args.poll_interval,
|
|
613
|
+
"heartbeat_interval": args.heartbeat_interval,
|
|
614
|
+
})
|
|
615
|
+
if args.device_token:
|
|
616
|
+
cfg["device_token"] = args.device_token
|
|
617
|
+
_save_token(args.device_token)
|
|
618
|
+
if args.enrollment_token:
|
|
619
|
+
cfg["enrollment_token"] = args.enrollment_token
|
|
620
|
+
|
|
621
|
+
if args.save_config:
|
|
622
|
+
_save_config(cfg)
|
|
623
|
+
logger.info(f"saved config to {CONFIG_FILE}")
|
|
624
|
+
|
|
625
|
+
agent = PreviewAgent(cfg)
|
|
626
|
+
|
|
627
|
+
loop = asyncio.new_event_loop()
|
|
628
|
+
asyncio.set_event_loop(loop)
|
|
629
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
630
|
+
loop.add_signal_handler(sig, agent.shutdown)
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
loop.run_until_complete(agent.run())
|
|
634
|
+
finally:
|
|
635
|
+
loop.close()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
if __name__ == "__main__":
|
|
639
|
+
main()
|