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.
Files changed (30) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +32 -1
  3. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  4. package/dist/robopark/preview-agent-launcher.js +8 -0
  5. package/dist/robopark-cli.js +3 -0
  6. package/package.json +3 -1
  7. package/packages/robopark/pi-client/README.md +17 -0
  8. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  9. package/packages/robopark/pi-client/client.py +305 -0
  10. package/packages/robopark/pi-client/install.sh +41 -0
  11. package/packages/robopark/pi-client/join_convo.sh +55 -0
  12. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  13. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  14. package/packages/robopark/pi-client/pi_ui.py +375 -0
  15. package/packages/robopark/pi-client/requirements.txt +10 -0
  16. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  17. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  18. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  19. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  20. package/packages/robopark/scheduler/main.py +45 -9
  21. package/packages/robopark/scheduler/preview_agent.py +132 -3
  22. package/packages/robopark/vision/.env.example +7 -0
  23. package/packages/robopark/vision/README.md +63 -0
  24. package/packages/robopark/vision/app_pi_clean.py +307 -0
  25. package/packages/robopark/vision/audio_server_pi.py +751 -0
  26. package/packages/robopark/vision/install.sh +34 -0
  27. package/packages/robopark/vision/motor_server.py +304 -0
  28. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  29. package/packages/robopark/vision/run.sh +244 -0
  30. 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}"