infinicode 2.8.46 β†’ 2.8.51

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.
@@ -5867,7 +5867,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
5867
5867
  var effPresetOptions='', effVoiceOptions='';
5868
5868
  for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value="'+esc(cp.id||'')+'"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }
5869
5869
  for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value="'+esc(vs.id||'')+'">'+esc(vs.name||vs.id||'')+'</option>'; }
5870
- var devForPm=schedulerDevice(n)||{};
5870
+ var devForPm=schedulerDevice(n)||{};
5871
5871
  b.innerHTML =
5872
5872
  sect('πŸ‘ camera feed',
5873
5873
  '<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
@@ -5895,7 +5895,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
5895
5895
  + buildShellSection(schedId(n))
5896
5896
  + sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><button class="rp-btn" id="pk-eff-save" data-robot="'+esc(schedId(n))+'" style="margin-top:0.35rem;">Set up on this robot</button>');
5897
5897
  b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
5898
- refreshDeviceSelectors(n);
5898
+ // Put live hardware selection beside the camera and audio tests,
5899
+ // ahead of persona and service administration in the Park drawer.
5900
+ var deviceInput=$('pk-device-search'), deviceSection=deviceInput&&deviceInput.closest('.pk-sect');
5901
+ var speakerTest=$('pk-spk-test'), speakerSection=speakerTest&&speakerTest.closest('.pk-sect');
5902
+ if(deviceSection && speakerSection) speakerSection.after(deviceSection);
5903
+ refreshDeviceSelectors(n);
5899
5904
  b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
5900
5905
  var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
5901
5906
  var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
9
  import { homedir, hostname } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import chalk from 'chalk';
12
- import { findPython, findSchedulerPath, ensurePythonDeps } from './python-env.js';
12
+ import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
13
13
  const ROBOPARK_DIR = join(homedir(), '.robopark');
14
14
  const ENROLLMENT_FILE = join(ROBOPARK_DIR, 'enrollment_token');
15
15
  function ensureDir() {
@@ -39,8 +39,8 @@ export async function roboparkEnroll(opts) {
39
39
  console.log(chalk.red(' βœ— could not find preview_agent.py'));
40
40
  process.exit(1);
41
41
  }
42
- const python = findPython();
43
- if (!ensurePythonDeps(python, script))
42
+ const python = prepareRobotPython(findPython(), script);
43
+ if (!python)
44
44
  process.exit(1);
45
45
  const args = [
46
46
  script,
@@ -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
  }
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { hostname } from 'node:os';
9
9
  import chalk from 'chalk';
10
10
  import { discoverContext } from './discovery.js';
11
- import { findPython, findSchedulerPath, ensurePythonDeps } from './python-env.js';
11
+ import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
12
12
  const DEFAULT_SCHEDULER_PORT = 8080;
13
13
  export async function roboparkPreviewAgent(opts) {
14
14
  const ctx = await discoverContext();
@@ -24,8 +24,8 @@ export async function roboparkPreviewAgent(opts) {
24
24
  console.log(chalk.red(' βœ— could not find preview_agent.py'));
25
25
  process.exit(1);
26
26
  }
27
- const python = findPython();
28
- if (!ensurePythonDeps(python, script))
27
+ const python = prepareRobotPython(findPython(), script);
28
+ if (!python)
29
29
  process.exit(1);
30
30
  const args = [
31
31
  script,
@@ -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'));
@@ -13,3 +13,9 @@ export declare function findPython(): string;
13
13
  * (e.g. app_pi_clean.py + requirements_pi_unified.txt).
14
14
  */
15
15
  export declare function ensurePythonDeps(python: string, scriptPath: string, modules?: string[], reqFile?: string): boolean;
16
+ /**
17
+ * Create and maintain the isolated robot runtime at ~/.robopark/venv.
18
+ * System site packages stay visible so Pi apt packages such as OpenCV,
19
+ * PyAudio and Picamera2 do not need to be rebuilt by pip on ARM.
20
+ */
21
+ export declare function prepareRobotPython(basePython: string, scriptPath: string, modules?: string[], reqFile?: string): string | null;
@@ -3,8 +3,10 @@
3
3
  * and `vision-agent`, which spawn preview_agent.py / app_pi_clean.py.
4
4
  */
5
5
  import { execSync, spawnSync } from 'node:child_process';
6
- import { existsSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
7
8
  import { join } from 'node:path';
9
+ import { createHash } from 'node:crypto';
8
10
  import chalk from 'chalk';
9
11
  /** Modules preview_agent.py imports β€” kept in sync with requirements-robot.txt. */
10
12
  const REQUIRED_MODULES = ['httpx', 'cv2', 'pyaudio', 'livekit'];
@@ -52,6 +54,12 @@ export function findPython() {
52
54
  }
53
55
  return process.platform === 'win32' ? 'python' : 'python3';
54
56
  }
57
+ const ROBOPARK_VENV = join(homedir(), '.robopark', 'venv');
58
+ function venvPythonPath() {
59
+ return process.platform === 'win32'
60
+ ? join(ROBOPARK_VENV, 'Scripts', 'python.exe')
61
+ : join(ROBOPARK_VENV, 'bin', 'python');
62
+ }
55
63
  function missingModules(python, modules) {
56
64
  return modules.filter(mod => {
57
65
  const res = spawnSync(python, ['-c', `import ${mod}`], { stdio: 'ignore' });
@@ -89,3 +97,44 @@ export function ensurePythonDeps(python, scriptPath, modules = REQUIRED_MODULES,
89
97
  }
90
98
  return true;
91
99
  }
100
+ /**
101
+ * Create and maintain the isolated robot runtime at ~/.robopark/venv.
102
+ * System site packages stay visible so Pi apt packages such as OpenCV,
103
+ * PyAudio and Picamera2 do not need to be rebuilt by pip on ARM.
104
+ */
105
+ export function prepareRobotPython(basePython, scriptPath, modules = REQUIRED_MODULES, reqFile = 'requirements-robot.txt') {
106
+ const reqPath = join(scriptPath, '..', reqFile);
107
+ if (!existsSync(reqPath)) {
108
+ console.log(chalk.red(` βœ— ${reqFile} not found next to ${scriptPath}`));
109
+ return null;
110
+ }
111
+ const python = venvPythonPath();
112
+ if (!existsSync(python)) {
113
+ console.log(chalk.dim(` creating RoboPark Python environment: ${ROBOPARK_VENV}`));
114
+ mkdirSync(join(homedir(), '.robopark'), { recursive: true });
115
+ const created = spawnSync(basePython, ['-m', 'venv', '--system-site-packages', ROBOPARK_VENV], { stdio: 'inherit' });
116
+ if (created.status !== 0 || !existsSync(python)) {
117
+ console.log(chalk.red(' βœ— could not create ~/.robopark/venv. On Debian/Raspberry Pi install python3-venv, then retry.'));
118
+ return null;
119
+ }
120
+ }
121
+ const requirements = readFileSync(reqPath, 'utf8');
122
+ const fingerprint = createHash('sha256').update(requirements).digest('hex');
123
+ const marker = join(ROBOPARK_VENV, `.robopark-${reqFile}.sha256`);
124
+ const installedFingerprint = existsSync(marker) ? readFileSync(marker, 'utf8').trim() : '';
125
+ if (installedFingerprint !== fingerprint || missingModules(python, modules).length > 0) {
126
+ console.log(chalk.dim(` syncing RoboPark Python dependencies from ${reqFile}…`));
127
+ const install = spawnSync(python, ['-m', 'pip', 'install', '--upgrade', '-r', reqPath], { stdio: 'inherit' });
128
+ if (install.status !== 0) {
129
+ console.log(chalk.red(` βœ— dependency install failed in ${ROBOPARK_VENV}`));
130
+ return null;
131
+ }
132
+ writeFileSync(marker, `${fingerprint}\n`);
133
+ }
134
+ const stillMissing = missingModules(python, modules);
135
+ if (stillMissing.length) {
136
+ console.log(chalk.red(` βœ— venv is still missing: ${stillMissing.join(', ')}`));
137
+ return null;
138
+ }
139
+ return python;
140
+ }
@@ -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(' ')}`));
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { spawn } from 'node:child_process';
9
9
  import chalk from 'chalk';
10
- import { findPython, findVisionPath, ensurePythonDeps, VISION_REQUIRED_MODULES } from './python-env.js';
10
+ import { findPython, findVisionPath, prepareRobotPython, VISION_REQUIRED_MODULES } from './python-env.js';
11
11
  const DEFAULT_VISION_PORT = 5000;
12
12
  const DEFAULT_MOTION_WEBHOOK_PORT = 5057; // must match preview-agent's --vision-webhook-port default
13
13
  function isPiArm() {
@@ -19,12 +19,13 @@ export async function roboparkVisionAgent(opts) {
19
19
  console.log(chalk.red(' βœ— could not find app_pi_clean.py'));
20
20
  process.exit(1);
21
21
  }
22
- const python = findPython();
22
+ const basePython = findPython();
23
23
  // Real Pi: opencv comes from `apt install python3-opencv`, not pip (see
24
24
  // requirements_pi_unified.txt) β€” pip-installing it here would fight the
25
25
  // ARM-optimized system build. Everywhere else: plain pip install works.
26
26
  const reqFile = isPiArm() ? 'requirements_pi_unified.txt' : 'requirements-demo.txt';
27
- if (!ensurePythonDeps(python, script, VISION_REQUIRED_MODULES, reqFile))
27
+ const python = prepareRobotPython(basePython, script, VISION_REQUIRED_MODULES, reqFile);
28
+ if (!python)
28
29
  process.exit(1);
29
30
  const port = opts.port ?? String(DEFAULT_VISION_PORT);
30
31
  const webhookUrl = opts.motionWebhookUrl ?? `http://127.0.0.1:${DEFAULT_MOTION_WEBHOOK_PORT}/`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.46",
3
+ "version": "2.8.51",
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")
@@ -4235,6 +4235,7 @@ async def dashboard():
4235
4235
  const savedVideo = device?.video_device || 'auto';
4236
4236
  const savedAudio = device?.audio_device || 'default';
4237
4237
  const hardware = device?.device_inventory || {};
4238
+ const inventoryReported = !!device?.device_inventory;
4238
4239
  const videoOptions = (hardware.video || []).filter(Boolean);
4239
4240
  const audioOptions = (hardware.audio_input || []).filter(Boolean);
4240
4241
  const audioOutputOptions = (hardware.audio_output || []).filter(Boolean);
@@ -4251,6 +4252,7 @@ async def dashboard():
4251
4252
  map[robot.id] = {
4252
4253
  deviceId: device?.id || null,
4253
4254
  robotName: robot.name,
4255
+ inventoryReported,
4254
4256
  selectedVideo: savedVideo,
4255
4257
  selectedAudio: savedAudio,
4256
4258
  selectedAudioOutput: device?.audio_output_device || 'default',
@@ -5133,6 +5135,21 @@ async def dashboard():
5133
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>
5134
5136
  <button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
5135
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>
5136
5153
  <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
5137
5154
  <div>
5138
5155
  <label class="text-xs text-gray-500">Character</label>
@@ -5148,48 +5165,15 @@ async def dashboard():
5148
5165
  <div class="text-xs text-gray-400 px-2 py-1 bg-gray-800 rounded truncate">{{ stackName(robot.voice_stack_id) }}</div>
5149
5166
  </div>
5150
5167
  </div>
5151
- <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
5152
- <div class="grid grid-cols-3 gap-2">
5153
- <div>
5154
- <label class="text-xs text-gray-500">Camera</label>
5155
- <select v-model="deviceInventory[robot.id].selectedVideo"
5156
- @change="updateDeviceMedia(robot.id)"
5157
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5158
- <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>
5159
- </select>
5160
- </div>
5161
- <div>
5162
- <label class="text-xs text-gray-500">Microphone</label>
5163
- <select v-model="deviceInventory[robot.id].selectedAudio"
5164
- @change="updateDeviceMedia(robot.id)"
5165
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5166
- <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>
5167
- </select>
5168
- </div>
5169
- <div>
5170
- <label class="text-xs text-gray-500">Speaker</label>
5171
- <select v-model="deviceInventory[robot.id].selectedAudioOutput"
5172
- @change="updateDeviceMedia(robot.id)"
5173
- class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5174
- <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>
5175
- </select>
5176
- </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.
5177
5171
  </div>
5178
5172
  <div>
5179
5173
  <label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
5180
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>
5181
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>
5182
5176
  </div>
5183
- <button @click="startPreview(robot)"
5184
- :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
5185
- :class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
5186
- class="w-full px-2 py-1 rounded text-xs">
5187
- {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
5188
- </button>
5189
- <button @click="testSpeaker(robot)"
5190
- class="w-full px-2 py-1 rounded text-xs bg-emerald-700 hover:bg-emerald-600">
5191
- Test Speaker + Mic
5192
- </button>
5193
5177
  <div class="grid grid-cols-2 gap-2">
5194
5178
  <button @click="simulateTrigger(robot)"
5195
5179
  :disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"