infinicode 2.8.45 → 2.8.49

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.
@@ -11,6 +11,7 @@ export interface PreviewAgentOptions {
11
11
  visionWebhookPort?: string;
12
12
  visionTriggerCooldown?: string;
13
13
  visionSessionSeconds?: string;
14
+ robovisionUrl?: string;
14
15
  saveConfig?: boolean;
15
16
  foreground?: boolean;
16
17
  }
@@ -47,6 +47,8 @@ export async function roboparkPreviewAgent(opts) {
47
47
  args.push('--vision-trigger-cooldown', opts.visionTriggerCooldown);
48
48
  if (opts.visionSessionSeconds)
49
49
  args.push('--vision-session-seconds', opts.visionSessionSeconds);
50
+ if (opts.robovisionUrl)
51
+ args.push('--robovision-url', opts.robovisionUrl);
50
52
  if (opts.saveConfig)
51
53
  args.push('--save-config');
52
54
  console.log(chalk.bold('\n robopark preview-agent'));
@@ -225,6 +225,8 @@ async function setupRobot(config, opts, ctx, internal) {
225
225
  // production trigger — on by default. --no-vision opts out (e.g. no camera
226
226
  // on this box, or RoboVisionAI_PI isn't installed).
227
227
  const visionEnabled = opts.vision !== false;
228
+ if (visionEnabled)
229
+ previewAgentArgs.push('--robovision-url', 'http://127.0.0.1:5000');
228
230
  const visionAgentArgs = ['vision-agent'];
229
231
  console.log(chalk.dim(' robot command:'));
230
232
  console.log(' ' + chalk.cyan(`infinicode ${args.join(' ')}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.45",
3
+ "version": "2.8.49",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -20,13 +20,15 @@
20
20
  # Subsequent runs (token is in device.json):
21
21
  # python client.py --scheduler-url http://100.64.1.5:8080
22
22
 
23
- import argparse
24
- import asyncio
25
- import json
26
- import logging
27
- import os
28
- import signal
29
- import sys
23
+ import argparse
24
+ import asyncio
25
+ import glob
26
+ import json
27
+ import logging
28
+ import os
29
+ import signal
30
+ import subprocess
31
+ import sys
30
32
  import time
31
33
  from pathlib import Path
32
34
  from typing import Optional
@@ -39,7 +41,45 @@ logging.basicConfig(
39
41
  level=logging.INFO,
40
42
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
41
43
  )
42
- log = logging.getLogger("robopark-pi")
44
+ log = logging.getLogger("robopark-pi")
45
+
46
+
47
+ def get_device_inventory() -> dict:
48
+ """Report real Linux media devices so the remote dashboard can select them."""
49
+ inventory = {
50
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}],
51
+ "audio_input": [{"id": "default", "name": "System default input"}],
52
+ "audio_output": [{"id": "default", "name": "System default output"}],
53
+ "platform": sys.platform,
54
+ }
55
+ for path in sorted(glob.glob("/dev/video*")):
56
+ inventory["video"].append({"id": path, "name": path, "backend": "v4l2"})
57
+
58
+ try:
59
+ import pyaudio
60
+ pa = pyaudio.PyAudio()
61
+ for index in range(pa.get_device_count()):
62
+ info = pa.get_device_info_by_index(index)
63
+ name = str(info.get("name", f"Audio device {index}"))
64
+ item = {"id": str(index), "name": name, "host_api": str(info.get("hostApi", ""))}
65
+ if info.get("maxInputChannels", 0) > 0:
66
+ inventory["audio_input"].append(item.copy())
67
+ if info.get("maxOutputChannels", 0) > 0:
68
+ inventory["audio_output"].append(item.copy())
69
+ pa.terminate()
70
+ except Exception as exc:
71
+ # Keep the device visible even when PortAudio is not installed; ALSA
72
+ # names are still useful for an operator diagnosing a Pi remotely.
73
+ log.debug("PyAudio inventory unavailable: %s", exc)
74
+ for command, key, kind in (("arecord", "audio_input", "input"), ("aplay", "audio_output", "output")):
75
+ try:
76
+ output = subprocess.run([command, "-L"], capture_output=True, text=True, timeout=3, check=False).stdout
77
+ for name in (line.strip() for line in output.splitlines()):
78
+ if name and not name.startswith("(") and name not in {x["id"] for x in inventory[key]}:
79
+ inventory[key].append({"id": name, "name": name, "backend": "alsa", "kind": kind})
80
+ except Exception:
81
+ pass
82
+ return inventory
43
83
 
44
84
 
45
85
  class PiClient:
@@ -56,7 +96,10 @@ class PiClient:
56
96
  self.heartbeat_interval = args.heartbeat_interval
57
97
  self.config_poll_interval = args.config_poll_interval
58
98
  self.join_room = args.join_room
59
- self.room_name = args.room_name
99
+ self.room_name = args.room_name
100
+ self.video_device = args.video_device
101
+ self.audio_device = args.audio_device
102
+ self.audio_output_device = args.audio_output_device
60
103
 
61
104
  self.device_id: Optional[str] = None
62
105
  self.device_token: Optional[str] = None
@@ -129,8 +172,12 @@ class PiClient:
129
172
  log.info(f"Enrolled as device_id={self.device_id}")
130
173
  self._save_state()
131
174
 
132
- async def heartbeat(self):
133
- body = {"status": "online", "ip": self.tailscale_ip or self.lan_ip}
175
+ async def heartbeat(self):
176
+ body = {
177
+ "status": "online",
178
+ "ip": self.tailscale_ip or self.lan_ip,
179
+ "device_inventory": get_device_inventory(),
180
+ }
134
181
  async with httpx.AsyncClient(timeout=10.0) as c:
135
182
  r = await c.post(
136
183
  f"{self.scheduler_url}/api/devices/{self.device_id}/heartbeat",
@@ -186,7 +233,10 @@ class PiClient:
186
233
  production_mode_provider=lambda: self.production_mode,
187
234
  scheduler_url=self.scheduler_url,
188
235
  device_id=self.device_id,
189
- device_token=self.device_token,
236
+ device_token=self.device_token,
237
+ video_device=self.video_device,
238
+ audio_device=self.audio_device,
239
+ audio_output_device=self.audio_output_device,
190
240
  )
191
241
 
192
242
  # ---- main loop ------------------------------------------------------
@@ -234,8 +284,14 @@ class PiClient:
234
284
  if int(time.time()) % int(self.config_poll_interval) < int(self.heartbeat_interval):
235
285
  cfg = await self.fetch_config()
236
286
  self.production_mode = bool(cfg.get("production_mode", False))
237
- if cfg.get("character_id"):
238
- self.character_id = cfg["character_id"]
287
+ if cfg.get("character_id"):
288
+ self.character_id = cfg["character_id"]
289
+ if cfg.get("video_device") is not None:
290
+ self.video_device = cfg["video_device"]
291
+ if cfg.get("audio_device") is not None:
292
+ self.audio_device = cfg["audio_device"]
293
+ if cfg.get("audio_output_device") is not None:
294
+ self.audio_output_device = cfg["audio_output_device"]
239
295
  except Exception as e:
240
296
  log.debug(f"Config poll error: {e}")
241
297
 
@@ -267,8 +323,11 @@ def parse_args() -> argparse.Namespace:
267
323
  help="Base URL of the local RoboVisionAI_PI motor server")
268
324
  p.add_argument("--livekit-url", default=None, help="e.g. ws://100.64.1.5:7880")
269
325
  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")
326
+ p.add_argument("--room-name", default="robopark-pi-standby",
327
+ help="LiveKit room to join")
328
+ p.add_argument("--video-device", default="auto", help="Camera path/index, picamera, or auto")
329
+ p.add_argument("--audio-device", default="default", help="Microphone name/index or default")
330
+ p.add_argument("--audio-output-device", default="default", help="Speaker name/index or default")
272
331
  p.add_argument("--heartbeat-interval", type=float, default=10.0)
273
332
  p.add_argument("--config-poll-interval", type=float, default=30.0)
274
333
  p.add_argument("--join-room", action="store_true",
@@ -302,4 +361,4 @@ def main():
302
361
 
303
362
 
304
363
  if __name__ == "__main__":
305
- main()
364
+ main()
@@ -75,9 +75,12 @@ async def run_livekit(
75
75
  on_motor_command: Callable[[str | bytes], Awaitable[str]],
76
76
  stop_event: asyncio.Event,
77
77
  production_mode_provider: Callable[[], bool],
78
- scheduler_url: str = "",
79
- device_id: str = "",
80
- device_token: str = "",
78
+ scheduler_url: str = "",
79
+ device_id: str = "",
80
+ device_token: str = "",
81
+ video_device: str = "auto",
82
+ audio_device: str = "default",
83
+ audio_output_device: str = "default",
81
84
  ):
82
85
  """Join a LiveKit room, publish mic + cam, subscribe to data channel.
83
86
 
@@ -140,7 +143,8 @@ async def run_livekit(
140
143
  try:
141
144
  stream = rtc.AudioStream(track=track)
142
145
  # Open output stream at 48kHz mono (Pi speaker)
143
- out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0)
146
+ output = None if str(audio_output_device).lower() in ("", "default", "auto") else audio_output_device
147
+ out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0, device=output)
144
148
  out.start()
145
149
  _active_streams[sid] = out
146
150
 
@@ -203,13 +207,16 @@ async def run_livekit(
203
207
  except Exception as e:
204
208
  log.warning(f"sounddevice unavailable, skipping mic: {e}")
205
209
  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
210
+ selected_mic = str(audio_device or "default")
211
+ MIC_DEVICE = None if selected_mic.lower() in ("", "default", "auto") else selected_mic
212
+ try:
213
+ if MIC_DEVICE is None:
214
+ log.info("mic: opening system default input")
215
+ else:
216
+ log.info(f"mic: opening device {MIC_DEVICE}")
217
+ except Exception:
218
+ log.warning(f"mic: device {MIC_DEVICE} not found, trying default")
219
+ MIC_DEVICE = None
213
220
  try:
214
221
  source = rtc.AudioSource(sample_rate=16000, num_channels=1)
215
222
  track = rtc.LocalAudioTrack.create_audio_track("mic", source)
@@ -241,9 +248,11 @@ async def run_livekit(
241
248
  except Exception as e:
242
249
  log.warning(f"opencv unavailable, skipping camera: {e}")
243
250
  return
244
- cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
245
- if not cap.isOpened():
246
- log.warning("camera /dev/video0 not available")
251
+ selected_camera = str(video_device or "auto")
252
+ camera_index = 0 if selected_camera.lower() in ("", "auto", "default", "first") else selected_camera
253
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_V4L2)
254
+ if not cap.isOpened():
255
+ log.warning(f"camera {selected_camera} not available")
247
256
  return
248
257
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
249
258
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
@@ -286,4 +295,4 @@ async def run_livekit(
286
295
  except Exception: pass
287
296
  try: await room.disconnect()
288
297
  except Exception: pass
289
- log.info("left room, will loop to reconnect")
298
+ log.info("left room, will loop to reconnect")
@@ -4230,23 +4230,37 @@ async def dashboard():
4230
4230
  const buildDeviceInventory = () => {
4231
4231
  const map = {};
4232
4232
  for (const robot of robots.value) {
4233
- const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
4233
+ const robotName = String(robot.name || '').toLowerCase();
4234
+ const device = devices.value.find(d => String(d.name || '').toLowerCase() === robotName || d.id === robot.id);
4234
4235
  const savedVideo = device?.video_device || 'auto';
4235
4236
  const savedAudio = device?.audio_device || 'default';
4236
4237
  const hardware = device?.device_inventory || {};
4237
- const videoOptions = (hardware.video || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4238
- const audioOptions = (hardware.audio_input || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4239
- const audioOutputOptions = (hardware.audio_output || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4238
+ const inventoryReported = !!device?.device_inventory;
4239
+ const videoOptions = (hardware.video || []).filter(Boolean);
4240
+ const audioOptions = (hardware.audio_input || []).filter(Boolean);
4241
+ const audioOutputOptions = (hardware.audio_output || []).filter(Boolean);
4242
+ const optionId = x => typeof x === 'string' ? x : x.id;
4243
+ const optionName = x => typeof x === 'string' ? x : (x.name || x.id);
4244
+ const uniqueOptions = (items, defaults) => {
4245
+ const seen = new Set();
4246
+ return defaults.concat(items).filter(x => {
4247
+ const id = optionId(x);
4248
+ if (!id || seen.has(id)) return false;
4249
+ seen.add(id); return true;
4250
+ });
4251
+ };
4240
4252
  map[robot.id] = {
4241
4253
  deviceId: device?.id || null,
4242
4254
  robotName: robot.name,
4255
+ inventoryReported,
4243
4256
  selectedVideo: savedVideo,
4244
4257
  selectedAudio: savedAudio,
4245
4258
  selectedAudioOutput: device?.audio_output_device || 'default',
4246
4259
  greetingText: (device?.greeting_phrases || []).join('\\n'),
4247
- videoOptions: [...new Set(['auto', 'none', savedVideo, ...videoOptions])],
4248
- audioOptions: [...new Set(['default', 'none', savedAudio, ...audioOptions])],
4249
- audioOutputOptions: [...new Set(['default', 'none', device?.audio_output_device || 'default', ...audioOutputOptions])],
4260
+ videoOptions: uniqueOptions(videoOptions, ['auto', 'none', savedVideo]),
4261
+ audioOptions: uniqueOptions(audioOptions, ['default', 'none', savedAudio]),
4262
+ audioOutputOptions: uniqueOptions(audioOutputOptions, ['default', 'none', device?.audio_output_device || 'default']),
4263
+ optionId, optionName,
4250
4264
  };
4251
4265
  }
4252
4266
  deviceInventory.value = map;
@@ -4366,7 +4380,7 @@ async def dashboard():
4366
4380
  const inv = deviceInventory.value[robotId];
4367
4381
  if (!inv || !inv.deviceId) return;
4368
4382
  try {
4369
- await fetch(`/api/devices/${inv.deviceId}`, {
4383
+ const response = await fetch(`/api/devices/${inv.deviceId}`, {
4370
4384
  method: 'PATCH',
4371
4385
  headers: {'Content-Type': 'application/json'},
4372
4386
  body: JSON.stringify({
@@ -4416,6 +4430,31 @@ async def dashboard():
4416
4430
  } catch (e) { console.warn('preview stop failed', e); }
4417
4431
  }
4418
4432
  };
4433
+
4434
+ const testSpeaker = async (robot) => {
4435
+ const inv = deviceInventory.value[robot.id];
4436
+ const deviceId = inv?.deviceId || robot.id;
4437
+ try {
4438
+ const response = await fetch(`/api/robots/${deviceId}/shell/speaker-test`, {
4439
+ method: 'POST', headers: {'Content-Type': 'application/json'},
4440
+ body: JSON.stringify({
4441
+ output: inv?.selectedAudioOutput || 'default',
4442
+ input: inv?.selectedAudio || 'default',
4443
+ duration: 0.6,
4444
+ }),
4445
+ });
4446
+ const queued = await response.json();
4447
+ if (!response.ok) throw new Error(queued.detail || `HTTP ${response.status}`);
4448
+ const result = await fetch(`/api/robots/${deviceId}/shell/wait/${queued.request_id}?timeout=12`).then(r => r.json());
4449
+ if (!result.completed) throw new Error('Robot did not answer. Check its heartbeat and supervisor.');
4450
+ const detail = result.result || {};
4451
+ alert(detail.ok
4452
+ ? `Audio test passed\\nOutput: ${detail.output_device || 'default'}\\nInput: ${detail.input_device || 'default'}\\nPeak: ${detail.peak_db ?? 'n/a'} dB`
4453
+ : `Audio test failed: ${detail.error || 'no microphone signal'}`);
4454
+ } catch (e) {
4455
+ alert('Audio test failed: ' + e.message);
4456
+ }
4457
+ };
4419
4458
 
4420
4459
  const simulateTrigger = async (robot) => {
4421
4460
  if (!settings.value.production_mode || simulation.value.busy) return;
@@ -4766,14 +4805,14 @@ async def dashboard():
4766
4805
  return {
4767
4806
  robots, servers, sessions, stats, connected, serverMetrics,
4768
4807
  devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandHubLan, commandHubTailscale, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
4769
- showLiveKitConfig, livekitForm, preview, deviceInventory,
4808
+ showLiveKitConfig, livekitForm, preview, deviceInventory,
4770
4809
  voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
4771
4810
  editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
4772
4811
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
4773
4812
  toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
4774
4813
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
4775
4814
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
4776
- startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
4815
+ startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia, testSpeaker,
4777
4816
  openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
4778
4817
  openCharacterForm, saveCharacter, deleteCharacter,
4779
4818
  assignRobotCharacter, stackName, characterName,
@@ -5096,6 +5135,21 @@ async def dashboard():
5096
5135
  <span>Robot production: <strong :class="devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'text-green-400' : 'text-gray-400'">{{ devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'ON' : 'OFF' }}</strong></span>
5097
5136
  <button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
5098
5137
  </div>
5138
+ <div v-if="deviceInventory[robot.id]" class="mb-3 rounded bg-gray-800/70 p-2">
5139
+ <div class="flex items-center justify-between mb-2 text-xs font-semibold text-gray-300">
5140
+ <span>Robot Hardware</span>
5141
+ <span :class="deviceInventory[robot.id].inventoryReported ? 'text-green-300' : 'text-amber-300'">{{ deviceInventory[robot.id].inventoryReported ? 'inventory reported' : 'waiting for heartbeat' }}</span>
5142
+ </div>
5143
+ <div class="grid grid-cols-3 gap-2 text-sm">
5144
+ <div><label class="text-xs text-gray-500">Camera</label><select v-model="deviceInventory[robot.id].selectedVideo" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].videoOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
5145
+ <div><label class="text-xs text-gray-500">Microphone</label><select v-model="deviceInventory[robot.id].selectedAudio" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].audioOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
5146
+ <div><label class="text-xs text-gray-500">Speaker</label><select v-model="deviceInventory[robot.id].selectedAudioOutput" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
5147
+ </div>
5148
+ <div class="grid grid-cols-2 gap-2 mt-2">
5149
+ <button @click="startPreview(robot)" :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active" class="px-2 py-1 rounded text-xs bg-cyan-600 hover:bg-cyan-500 disabled:bg-gray-700 disabled:cursor-not-allowed">Camera Feed</button>
5150
+ <button @click="testSpeaker(robot)" class="px-2 py-1 rounded text-xs bg-emerald-700 hover:bg-emerald-600">Audio Test</button>
5151
+ </div>
5152
+ </div>
5099
5153
  <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
5100
5154
  <div>
5101
5155
  <label class="text-xs text-gray-500">Character</label>
@@ -5111,44 +5165,15 @@ async def dashboard():
5111
5165
  <div class="text-xs text-gray-400 px-2 py-1 bg-gray-800 rounded truncate">{{ stackName(robot.voice_stack_id) }}</div>
5112
5166
  </div>
5113
5167
  </div>
5114
- <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
5115
- <div class="grid grid-cols-3 gap-2">
5116
- <div>
5117
- <label class="text-xs text-gray-500">Camera</label>
5118
- <select v-model="deviceInventory[robot.id].selectedVideo"
5119
- @change="updateDeviceMedia(robot.id)"
5120
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5121
- <option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
5122
- </select>
5123
- </div>
5124
- <div>
5125
- <label class="text-xs text-gray-500">Microphone</label>
5126
- <select v-model="deviceInventory[robot.id].selectedAudio"
5127
- @change="updateDeviceMedia(robot.id)"
5128
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5129
- <option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
5130
- </select>
5131
- </div>
5132
- <div>
5133
- <label class="text-xs text-gray-500">Speaker</label>
5134
- <select v-model="deviceInventory[robot.id].selectedAudioOutput"
5135
- @change="updateDeviceMedia(robot.id)"
5136
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5137
- <option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
5138
- </select>
5139
- </div>
5168
+ <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
5169
+ <div v-if="!deviceInventory[robot.id].inventoryReported" class="rounded bg-amber-900/40 border border-amber-700 px-2 py-1 text-xs text-amber-200">
5170
+ Hardware inventory not reported yet. Keep the Pi client or preview agent running for one heartbeat, then refresh.
5140
5171
  </div>
5141
5172
  <div>
5142
5173
  <label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
5143
5174
  <textarea v-model="deviceInventory[robot.id].greetingText" rows="2" class="w-full bg-gray-800 rounded px-2 py-1 text-xs" placeholder="Hello! Want to go for a ride?"></textarea>
5144
5175
  <button @click="saveDeviceGreetings(robot.id)" class="mt-1 px-2 py-1 rounded bg-gray-600 hover:bg-gray-500 text-xs">Save Greetings</button>
5145
5176
  </div>
5146
- <button @click="startPreview(robot)"
5147
- :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
5148
- :class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
5149
- class="w-full px-2 py-1 rounded text-xs">
5150
- {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
5151
- </button>
5152
5177
  <div class="grid grid-cols-2 gap-2">
5153
5178
  <button @click="simulateTrigger(robot)"
5154
5179
  :disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
@@ -66,7 +66,10 @@ import httpx
66
66
 
67
67
  logger = logging.getLogger("robopark.preview_agent")
68
68
 
69
- _DEVICE_INVENTORY_CACHE: Optional[dict] = None
69
+ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
+ _DEVICE_INVENTORY_CACHE_AT = 0.0
71
+ DEVICE_INVENTORY_CACHE_SECONDS = 30.0
72
+ ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
70
73
 
71
74
 
72
75
  def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
@@ -131,58 +134,78 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
131
134
  pa.terminate()
132
135
 
133
136
 
134
- def _get_device_inventory() -> dict:
135
- """Return discoverable camera and audio devices for dashboard selection."""
136
- global _DEVICE_INVENTORY_CACHE
137
- if _DEVICE_INVENTORY_CACHE is not None:
138
- return _DEVICE_INVENTORY_CACHE
139
-
140
- inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
141
- inventory["video"].append({"id": "auto", "name": "Auto detect"})
142
- inventory["video"].append({"id": "none", "name": "Disable camera"})
143
-
144
- try:
145
- import cv2
146
- candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
147
- for candidate in candidates:
148
- value = int(candidate) if os.name == "nt" else candidate
149
- backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
150
- cap = cv2.VideoCapture(value, backend)
151
- if cap.isOpened():
152
- device_id = str(value)
153
- inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
154
- cap.release()
155
- except Exception as e:
156
- logger.debug(f"camera inventory unavailable: {e}")
157
-
158
- try:
159
- import pyaudio
160
- pa = pyaudio.PyAudio()
161
- default_in = None
162
- default_out = None
163
- try:
164
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
165
- default_in = wasapi.get("defaultInputDevice")
166
- default_out = wasapi.get("defaultOutputDevice")
167
- except Exception:
168
- pass
169
- inventory["audio_input"].append({"id": "default", "name": "System default input"})
170
- inventory["audio_output"].append({"id": "default", "name": "System default output"})
171
- for i in range(pa.get_device_count()):
172
- info = pa.get_device_info_by_index(i)
173
- name = str(info.get("name", f"Audio device {i}"))
174
- item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
175
- if info.get("maxInputChannels", 0) > 0:
176
- item["default"] = i == default_in
177
- inventory["audio_input"].append(item.copy())
178
- if info.get("maxOutputChannels", 0) > 0:
179
- item["default"] = i == default_out
180
- inventory["audio_output"].append(item.copy())
181
- pa.terminate()
182
- except Exception as e:
183
- logger.debug(f"audio inventory unavailable: {e}")
184
-
185
- _DEVICE_INVENTORY_CACHE = inventory
137
+ def _get_device_inventory() -> dict:
138
+ """Return discoverable camera and audio devices for dashboard selection."""
139
+ global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
140
+ now = time.monotonic()
141
+ if (_DEVICE_INVENTORY_CACHE is not None
142
+ and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
143
+ return _DEVICE_INVENTORY_CACHE
144
+
145
+ inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
146
+ inventory["video"].append({"id": "auto", "name": "Auto detect"})
147
+ inventory["video"].append({"id": "none", "name": "Disable camera"})
148
+
149
+ # RoboVisionAI_PI owns the production camera. Prefer its native inventory
150
+ # so this process never probes an already-open V4L2 device just to fill a
151
+ # dashboard dropdown.
152
+ robovision_inventory = None
153
+ try:
154
+ response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
155
+ if response.is_success:
156
+ robovision_inventory = response.json()
157
+ for key in ("video", "audio_input", "audio_output"):
158
+ if isinstance(robovision_inventory.get(key), list):
159
+ inventory[key] = robovision_inventory[key]
160
+ inventory["source"] = robovision_inventory.get("source", "robovision_pi")
161
+ except Exception:
162
+ pass
163
+
164
+ if not robovision_inventory:
165
+ try:
166
+ import cv2
167
+ candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
168
+ for candidate in candidates:
169
+ value = int(candidate) if os.name == "nt" else candidate
170
+ backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
171
+ cap = cv2.VideoCapture(value, backend)
172
+ if cap.isOpened():
173
+ device_id = str(value)
174
+ inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
175
+ cap.release()
176
+ except Exception as e:
177
+ logger.debug(f"camera inventory unavailable: {e}")
178
+
179
+ if not robovision_inventory:
180
+ try:
181
+ import pyaudio
182
+ pa = pyaudio.PyAudio()
183
+ default_in = None
184
+ default_out = None
185
+ try:
186
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
187
+ default_in = wasapi.get("defaultInputDevice")
188
+ default_out = wasapi.get("defaultOutputDevice")
189
+ except Exception:
190
+ pass
191
+ inventory["audio_input"].append({"id": "default", "name": "System default input"})
192
+ inventory["audio_output"].append({"id": "default", "name": "System default output"})
193
+ for i in range(pa.get_device_count()):
194
+ info = pa.get_device_info_by_index(i)
195
+ name = str(info.get("name", f"Audio device {i}"))
196
+ item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
197
+ if info.get("maxInputChannels", 0) > 0:
198
+ item["default"] = i == default_in
199
+ inventory["audio_input"].append(item.copy())
200
+ if info.get("maxOutputChannels", 0) > 0:
201
+ item["default"] = i == default_out
202
+ inventory["audio_output"].append(item.copy())
203
+ pa.terminate()
204
+ except Exception as e:
205
+ logger.debug(f"audio inventory unavailable: {e}")
206
+
207
+ _DEVICE_INVENTORY_CACHE = inventory
208
+ _DEVICE_INVENTORY_CACHE_AT = now
186
209
  logger.info(
187
210
  f"device inventory: {len(inventory['video']) - 2} cameras, "
188
211
  f"{len(inventory['audio_input']) - 1} inputs, "
@@ -313,8 +336,10 @@ class PreviewAgent:
313
336
  self.device_id: Optional[str] = cfg.get("device_id")
314
337
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
315
338
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
316
- self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
317
- self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
339
+ self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
340
+ self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
341
+ self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
342
+ self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
318
343
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
319
344
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
320
345
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -519,7 +544,7 @@ class PreviewAgent:
519
544
  if data.get("trigger"):
520
545
  await self._on_vision_motion({"source": data.get("source", "dashboard")})
521
546
 
522
- async def _poll_device_config(self) -> None:
547
+ async def _poll_device_config(self) -> None:
523
548
  """Apply dashboard-selected camera and microphone IDs before preview."""
524
549
  if not self._session or not self.device_id or not self.device_token:
525
550
  return
@@ -533,16 +558,41 @@ class PreviewAgent:
533
558
  headers={"Authorization": f"Bearer {self.device_token}"},
534
559
  timeout=10.0,
535
560
  )
536
- r.raise_for_status()
537
- data = r.json()
538
- if data.get("video_device") is not None:
539
- self.video_device = data["video_device"]
540
- if data.get("audio_device") is not None:
541
- self.audio_device = data["audio_device"]
542
- if data.get("audio_output_device") is not None:
543
- self.audio_output_device = data["audio_output_device"]
544
- except Exception as e:
545
- logger.debug(f"device config poll failed: {e}")
561
+ r.raise_for_status()
562
+ data = r.json()
563
+ changed = False
564
+ if data.get("video_device") is not None:
565
+ changed = changed or self.video_device != data["video_device"]
566
+ self.video_device = data["video_device"]
567
+ if data.get("audio_device") is not None:
568
+ changed = changed or self.audio_device != data["audio_device"]
569
+ self.audio_device = data["audio_device"]
570
+ if data.get("audio_output_device") is not None:
571
+ changed = changed or self.audio_output_device != data["audio_output_device"]
572
+ self.audio_output_device = data["audio_output_device"]
573
+ if changed:
574
+ await self._sync_robovision_media_config()
575
+ except Exception as e:
576
+ logger.debug(f"device config poll failed: {e}")
577
+
578
+ async def _sync_robovision_media_config(self) -> None:
579
+ """Apply scheduler media choices to the local RoboVisionAI_PI owner."""
580
+ try:
581
+ response = await self._session.post(
582
+ f"{self.robovision_url.rstrip('/')}/api/media/config",
583
+ json={
584
+ "video_device": self.video_device,
585
+ "audio_device": self.audio_device,
586
+ "audio_output_device": self.audio_output_device,
587
+ },
588
+ timeout=1.0,
589
+ )
590
+ if response.is_success:
591
+ global _DEVICE_INVENTORY_CACHE
592
+ _DEVICE_INVENTORY_CACHE = None
593
+ except Exception:
594
+ # RoboVision is optional on laptops and simulation nodes.
595
+ pass
546
596
 
547
597
  async def _check_vision_session(self) -> None:
548
598
  """Decide whether the active motion-triggered session should keep
@@ -903,7 +953,8 @@ class LiveKitPublisher:
903
953
  if video_enabled:
904
954
  try:
905
955
  self._capture = await asyncio.to_thread(
906
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
956
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
957
+ self.agent.robovision_url if self.agent.use_robovision_camera else None,
907
958
  )
908
959
  except Exception as e:
909
960
  # Camera availability must not block the microphone/session.
@@ -1384,15 +1435,26 @@ class Picamera2Capture(VideoCapture):
1384
1435
  pass
1385
1436
 
1386
1437
 
1387
- def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
1438
+ def create_video_capture(device: str, width: int, height: int, fps: int,
1439
+ robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
1388
1440
  if device.lower() in ("none", "", "false", "null"):
1389
1441
  return None
1390
- try:
1442
+ try:
1391
1443
  from livekit import rtc
1392
1444
  _ = rtc.VideoSource
1393
1445
  except Exception as e:
1394
1446
  logger.error(f"livekit python sdk not installed: {e}")
1395
- return None
1447
+ return None
1448
+
1449
+ if robovision_url:
1450
+ stream_url = f"{robovision_url.rstrip('/')}/video_feed"
1451
+ try:
1452
+ cap = OpencvVideoCapture(stream_url, width, height, fps)
1453
+ logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
1454
+ return cap
1455
+ except Exception as e:
1456
+ logger.error(f"RoboVision camera stream unavailable: {e}")
1457
+ return None
1396
1458
 
1397
1459
  # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
1398
1460
  if device.lower() in ("auto", "default", "first"):
@@ -1533,7 +1595,9 @@ def main() -> None:
1533
1595
  parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
1534
1596
  parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
1535
1597
  parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
1536
- parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
1598
+ parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
1599
+ parser.add_argument("--robovision-url", default=None,
1600
+ help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
1537
1601
  parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
1538
1602
  parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
1539
1603
  parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -1556,7 +1620,9 @@ def main() -> None:
1556
1620
  "scheduler_url": args.scheduler_url,
1557
1621
  "robot_id": args.robot_id,
1558
1622
  "video_device": args.video_device,
1559
- "audio_device": args.audio_device,
1623
+ "audio_device": args.audio_device,
1624
+ "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
1625
+ "use_robovision_camera": bool(args.robovision_url),
1560
1626
  "video_width": args.width,
1561
1627
  "video_height": args.height,
1562
1628
  "video_fps": args.fps,
@@ -1,7 +1,8 @@
1
1
  from flask import Flask, Response, jsonify, request
2
2
  from flask_cors import CORS
3
- import argparse
4
- import cv2
3
+ import argparse
4
+ import glob
5
+ import cv2
5
6
  import os
6
7
  import sys
7
8
  import time
@@ -14,8 +15,10 @@ app = Flask(__name__)
14
15
  CORS(app)
15
16
 
16
17
  # Camera management
17
- current_camera_index = 0
18
- camera = None
18
+ current_camera_index = 0
19
+ camera = None
20
+ audio_input_device = "default"
21
+ audio_output_device = "default"
19
22
 
20
23
  def _open_camera(index):
21
24
  # On Windows, cv2.VideoCapture(index) with no explicit backend can
@@ -312,21 +315,24 @@ def caption_mode():
312
315
  caption_mode_enabled = bool(data.get('enabled', False))
313
316
  return jsonify({"enabled": caption_mode_enabled})
314
317
 
315
- @app.route('/api/cameras', methods=['GET'])
316
- def list_cameras():
317
- available = []
318
- for i in range(4):
319
- cap = _open_camera(i)
320
- if cap.isOpened():
321
- available.append({"index": i, "name": f"Camera {i}"})
322
- cap.release()
323
- return jsonify({"cameras": available, "current": current_camera_index})
318
+ @app.route('/api/cameras', methods=['GET'])
319
+ def list_cameras():
320
+ available = []
321
+ candidates = sorted(glob.glob('/dev/video*')) if sys.platform != 'win32' else list(range(4))
322
+ for candidate in candidates:
323
+ cap = _open_camera(candidate)
324
+ if cap.isOpened():
325
+ available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "v4l2" if sys.platform != 'win32' else "dshow"})
326
+ cap.release()
327
+ return jsonify({"cameras": available, "current": current_camera_index})
324
328
 
325
329
  @app.route('/api/camera/switch', methods=['POST'])
326
- def switch_camera():
330
+ def switch_camera():
327
331
  global camera, current_camera_index
328
332
  data = request.json or {}
329
- new_index = int(data.get('index', 0))
333
+ new_index = data.get('device', data.get('index', 0))
334
+ if isinstance(new_index, str) and new_index.isdigit():
335
+ new_index = int(new_index)
330
336
 
331
337
  if camera:
332
338
  camera.release()
@@ -334,7 +340,64 @@ def switch_camera():
334
340
  current_camera_index = new_index
335
341
  camera = None
336
342
 
337
- return jsonify({"status": "ok", "camera_index": current_camera_index})
343
+ return jsonify({"status": "ok", "camera_index": current_camera_index})
344
+
345
+
346
+ def _audio_inventory():
347
+ inputs = [{"id": "default", "name": "System default input"}]
348
+ outputs = [{"id": "default", "name": "System default output"}]
349
+ try:
350
+ import pyaudio
351
+ pa = pyaudio.PyAudio()
352
+ for index in range(pa.get_device_count()):
353
+ info = pa.get_device_info_by_index(index)
354
+ item = {"id": str(index), "name": str(info.get("name", f"Audio device {index}")), "host_api": str(info.get("hostApi", ""))}
355
+ if info.get("maxInputChannels", 0) > 0:
356
+ inputs.append(item.copy())
357
+ if info.get("maxOutputChannels", 0) > 0:
358
+ outputs.append(item.copy())
359
+ pa.terminate()
360
+ except Exception:
361
+ pass
362
+ return inputs, outputs
363
+
364
+
365
+ @app.route('/api/media/inventory', methods=['GET'])
366
+ def media_inventory():
367
+ cameras = list_cameras().get_json().get("cameras", [])
368
+ if not cameras and sys.platform != 'win32':
369
+ # The active V4L2 handle may reject a second probe. The node path is
370
+ # still a valid selectable camera and is safer than hiding it.
371
+ cameras = [{"id": path, "index": path, "name": f"Camera {path}", "backend": "v4l2"}
372
+ for path in sorted(glob.glob('/dev/video*'))]
373
+ inputs, outputs = _audio_inventory()
374
+ return jsonify({
375
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
376
+ "audio_input": inputs,
377
+ "audio_output": outputs,
378
+ "selected": {"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device},
379
+ "source": "robovision_pi",
380
+ })
381
+
382
+
383
+ @app.route('/api/media/config', methods=['GET', 'POST'])
384
+ def media_config():
385
+ global audio_input_device, audio_output_device, camera, current_camera_index
386
+ if request.method == 'POST':
387
+ data = request.json or {}
388
+ if "video_device" in data:
389
+ value = str(data["video_device"])
390
+ if value not in ("", "auto", "none"):
391
+ switch_camera_value = value
392
+ if camera:
393
+ camera.release()
394
+ current_camera_index = int(switch_camera_value) if switch_camera_value.isdigit() else switch_camera_value
395
+ camera = None
396
+ if "audio_device" in data:
397
+ audio_input_device = str(data["audio_device"])
398
+ if "audio_output_device" in data:
399
+ audio_output_device = str(data["audio_output_device"])
400
+ return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
338
401
 
339
402
  @app.route('/api/motion/status', methods=['GET'])
340
403
  def motion_status():