infinicode 2.8.13 → 2.8.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark-cli.js +3 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +63 -0
- package/packages/robopark/vision/app_pi_clean.py +307 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- package/packages/robopark/vision/services/services.sh +12 -0
|
@@ -0,0 +1,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")
|
|
@@ -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}"
|