infinicode 2.8.44 → 2.8.46

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.
@@ -2032,7 +2032,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2032
2032
  var grid = $('rp-grid'), totals = $('rp-totals');
2033
2033
  // stash per-robot rows so the Park drawer can show live session stats
2034
2034
  roboparkByName = {};
2035
- robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
2035
+ robotsFrom(d).forEach(function(x){ roboparkByName[String(robotName(x)).toLowerCase()] = x; if(x.name) roboparkByName[String(x.name).toLowerCase()] = x; });
2036
2036
  // C4: also feed lastRobots for the Incidents sub-tab
2037
2037
  lastRobots = robotsFrom(d) || [];
2038
2038
  // push the same map into the Park so the canvas can color robots
@@ -3580,7 +3580,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
3580
3580
  connected: match?!!match.connected:false,
3581
3581
  load: (match&&match.load!=null)?match.load:0,
3582
3582
  platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
3583
- site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
3583
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
3584
+ schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',
3585
+ schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',
3584
3586
  telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
3585
3587
  wx:wx, wy:wy, _hit:prevHit(id)
3586
3588
  };
@@ -5312,7 +5314,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
5312
5314
  // enrolled device's id is a generated dev_xxxx, distinct from its name).
5313
5315
  // Falls back to n.name if telemetry hasn't matched a row yet.
5314
5316
  function schedRow(n){
5315
- var row=roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
5317
+ var row=roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
5316
5318
  var seen={}, candidates=[];
5317
5319
  for(var rk in roboparkByName){
5318
5320
  var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
@@ -5336,7 +5338,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
5336
5338
  function schedId(n){
5337
5339
  var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
5338
5340
  if(n.schedulerRobotId) return n.schedulerRobotId;
5339
- var byName=schedulerDevicesByName[n.name]; if(byName&&byName.id) return byName.id;
5341
+ var byName=schedulerDevicesByName[String(n.name||'').toLowerCase()]; if(byName&&byName.id) return byName.id;
5340
5342
  return n.name;
5341
5343
  }
5342
5344
  function closeDrawer(){
@@ -5466,13 +5468,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
5466
5468
  function pollRoboparkQuiet(){
5467
5469
  fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
5468
5470
  var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
5469
- list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });
5471
+ list.forEach(function(x){ roboparkByName[String(x.robot_id||x.id||x.name||'robot').toLowerCase()]=x; if(x.name) roboparkByName[String(x.name).toLowerCase()]=x; });
5470
5472
  refreshDrawer();
5471
5473
  }).catch(function(){});
5472
5474
  }
5473
5475
  function schedulerDevice(n){
5474
5476
  var row=schedRow(n), id=row&&(row.robot_id||row.id);
5475
- return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[n.name] || schedulerDevicesById[n.name] || null;
5477
+ return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerDeviceId] || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[String(n.name||'').toLowerCase()] || schedulerDevicesById[n.name] || null;
5476
5478
  }
5477
5479
  function deviceList(inv, key, fallback){
5478
5480
  var list=inv&&Array.isArray(inv[key])?inv[key]:[];
@@ -5619,7 +5621,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
5619
5621
  // available services from the supervisor_status report (if any),
5620
5622
  // otherwise shows a placeholder.
5621
5623
  function buildShellSection(robotId){
5622
- var device = robotId ? schedulerDeviceByName[robotName({name:robotId, id:robotId})] || null : null;
5624
+ var device = robotId ? schedulerDevicesByName[String(robotName({name:robotId, id:robotId})||'').toLowerCase()] || null : null;
5623
5625
  // Try to read the device row by id directly
5624
5626
  try{
5625
5627
  for(var k in schedulerDevicesById){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.44",
3
+ "version": "2.8.46",
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",
@@ -4166,6 +4166,8 @@ async def dashboard():
4166
4166
  const showDevices = ref(true);
4167
4167
  const showCommands = ref(false);
4168
4168
  const commandNetwork = ref('lan');
4169
+ const commandHubLan = ref('http://192.168.1.12:47913');
4170
+ const commandHubTailscale = ref('http://100.84.147.24:47913');
4169
4171
  const commandRobotId = ref('');
4170
4172
  const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
4171
4173
  const rotatedToken = ref(null);
@@ -4228,13 +4230,24 @@ async def dashboard():
4228
4230
  const buildDeviceInventory = () => {
4229
4231
  const map = {};
4230
4232
  for (const robot of robots.value) {
4231
- 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);
4232
4235
  const savedVideo = device?.video_device || 'auto';
4233
4236
  const savedAudio = device?.audio_device || 'default';
4234
4237
  const hardware = device?.device_inventory || {};
4235
- const videoOptions = (hardware.video || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4236
- const audioOptions = (hardware.audio_input || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4237
- const audioOutputOptions = (hardware.audio_output || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4238
+ const videoOptions = (hardware.video || []).filter(Boolean);
4239
+ const audioOptions = (hardware.audio_input || []).filter(Boolean);
4240
+ const audioOutputOptions = (hardware.audio_output || []).filter(Boolean);
4241
+ const optionId = x => typeof x === 'string' ? x : x.id;
4242
+ const optionName = x => typeof x === 'string' ? x : (x.name || x.id);
4243
+ const uniqueOptions = (items, defaults) => {
4244
+ const seen = new Set();
4245
+ return defaults.concat(items).filter(x => {
4246
+ const id = optionId(x);
4247
+ if (!id || seen.has(id)) return false;
4248
+ seen.add(id); return true;
4249
+ });
4250
+ };
4238
4251
  map[robot.id] = {
4239
4252
  deviceId: device?.id || null,
4240
4253
  robotName: robot.name,
@@ -4242,9 +4255,10 @@ async def dashboard():
4242
4255
  selectedAudio: savedAudio,
4243
4256
  selectedAudioOutput: device?.audio_output_device || 'default',
4244
4257
  greetingText: (device?.greeting_phrases || []).join('\\n'),
4245
- videoOptions: [...new Set(['auto', 'none', savedVideo, ...videoOptions])],
4246
- audioOptions: [...new Set(['default', 'none', savedAudio, ...audioOptions])],
4247
- audioOutputOptions: [...new Set(['default', 'none', device?.audio_output_device || 'default', ...audioOutputOptions])],
4258
+ videoOptions: uniqueOptions(videoOptions, ['auto', 'none', savedVideo]),
4259
+ audioOptions: uniqueOptions(audioOptions, ['default', 'none', savedAudio]),
4260
+ audioOutputOptions: uniqueOptions(audioOutputOptions, ['default', 'none', device?.audio_output_device || 'default']),
4261
+ optionId, optionName,
4248
4262
  };
4249
4263
  }
4250
4264
  deviceInventory.value = map;
@@ -4364,7 +4378,7 @@ async def dashboard():
4364
4378
  const inv = deviceInventory.value[robotId];
4365
4379
  if (!inv || !inv.deviceId) return;
4366
4380
  try {
4367
- await fetch(`/api/devices/${inv.deviceId}`, {
4381
+ const response = await fetch(`/api/devices/${inv.deviceId}`, {
4368
4382
  method: 'PATCH',
4369
4383
  headers: {'Content-Type': 'application/json'},
4370
4384
  body: JSON.stringify({
@@ -4414,6 +4428,31 @@ async def dashboard():
4414
4428
  } catch (e) { console.warn('preview stop failed', e); }
4415
4429
  }
4416
4430
  };
4431
+
4432
+ const testSpeaker = async (robot) => {
4433
+ const inv = deviceInventory.value[robot.id];
4434
+ const deviceId = inv?.deviceId || robot.id;
4435
+ try {
4436
+ const response = await fetch(`/api/robots/${deviceId}/shell/speaker-test`, {
4437
+ method: 'POST', headers: {'Content-Type': 'application/json'},
4438
+ body: JSON.stringify({
4439
+ output: inv?.selectedAudioOutput || 'default',
4440
+ input: inv?.selectedAudio || 'default',
4441
+ duration: 0.6,
4442
+ }),
4443
+ });
4444
+ const queued = await response.json();
4445
+ if (!response.ok) throw new Error(queued.detail || `HTTP ${response.status}`);
4446
+ const result = await fetch(`/api/robots/${deviceId}/shell/wait/${queued.request_id}?timeout=12`).then(r => r.json());
4447
+ if (!result.completed) throw new Error('Robot did not answer. Check its heartbeat and supervisor.');
4448
+ const detail = result.result || {};
4449
+ alert(detail.ok
4450
+ ? `Audio test passed\\nOutput: ${detail.output_device || 'default'}\\nInput: ${detail.input_device || 'default'}\\nPeak: ${detail.peak_db ?? 'n/a'} dB`
4451
+ : `Audio test failed: ${detail.error || 'no microphone signal'}`);
4452
+ } catch (e) {
4453
+ alert('Audio test failed: ' + e.message);
4454
+ }
4455
+ };
4417
4456
 
4418
4457
  const simulateTrigger = async (robot) => {
4419
4458
  if (!settings.value.production_mode || simulation.value.busy) return;
@@ -4527,11 +4566,9 @@ async def dashboard():
4527
4566
  const dismissRotatedToken = () => { rotatedToken.value = null; };
4528
4567
  const enrollmentCommand = (token) => {
4529
4568
  if (!token) return '';
4530
- const schedulerPort = window.location.port || '8080';
4531
4569
  const networkFlag = enrollmentNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4532
- const hubProtocol = window.location.protocol === 'https:' ? 'https://' : 'http://';
4533
- const hubUrl = `${hubProtocol}${window.location.hostname}:47913`;
4534
- return `robopark setup --robot --name "${token.name || token.device_id}" --hub-url ${hubUrl} --scheduler-port ${schedulerPort} ${networkFlag} --enrollment-token ${token.token} --start --auto-start`;
4570
+ const hubUrl = enrollmentNetwork.value === 'tailscale' ? commandHubTailscale.value : commandHubLan.value;
4571
+ return `robopark setup --robot --name "${token.name || token.device_id}" --hub-url ${hubUrl} --scheduler-port 8080 ${networkFlag} --enrollment-token ${token.token} --start --auto-start`;
4535
4572
  };
4536
4573
 
4537
4574
  const joinConversation = async (device) => {
@@ -4732,7 +4769,10 @@ async def dashboard():
4732
4769
  const stackName = (id) => voiceStacks.value.find(s => s.id === id)?.name || '—';
4733
4770
  const characterName = (id) => characterPresets.value.find(c => c.id === id)?.name || '—';
4734
4771
 
4735
- const schedulerBase = () => window.location.origin;
4772
+ const schedulerBase = () => {
4773
+ const hub = commandNetwork.value === 'tailscale' ? commandHubTailscale.value : commandHubLan.value;
4774
+ return hub.replace(/:\\d+\\/?$/, ':8080').replace(/\\/$/, '');
4775
+ };
4736
4776
  const selectedCommandRobot = computed(() => devices.value.find(d => d.id === commandRobotId.value) || devices.value[0] || null);
4737
4777
  const commandRows = computed(() => {
4738
4778
  const base = schedulerBase();
@@ -4741,10 +4781,11 @@ async def dashboard():
4741
4781
  const robotName = robot?.name || '<ROBOT_NAME>';
4742
4782
  const networkFlag = commandNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4743
4783
  const tokenPlaceholder = '<ENROLLMENT_TOKEN_FROM_ADD_DEVICE>';
4784
+ const hub = commandNetwork.value === 'tailscale' ? commandHubTailscale.value : commandHubLan.value;
4744
4785
  return [
4745
4786
  { group: 'Scheduler', name: 'Health check', description: 'Confirm the control center API is reachable.', command: `curl -s ${base}/api/settings` },
4746
4787
  { group: 'Scheduler', name: 'Start scheduler', description: 'Start the RoboPark scheduler and dashboard on port 8080.', command: 'robopark serve --port 8080' },
4747
- { group: 'Robot', name: 'Enroll robot', description: `Generated for ${robotName} over ${commandNetwork.value}. Paste the one-time token from the Add Device dialog.`, command: `robopark setup --robot --name "${robotName}" --hub-url ${base.replace(/:\\d+$/, '')}:47913 --scheduler-port ${window.location.port || '8080'} ${networkFlag} --enrollment-token ${tokenPlaceholder} --start --auto-start` },
4788
+ { group: 'Robot', name: 'Enroll robot', description: `Generated for ${robotName} over ${commandNetwork.value}. Paste the one-time token from the Add Device dialog.`, command: `robopark setup --robot --name "${robotName}" --hub-url ${hub} --scheduler-port 8080 ${networkFlag} --enrollment-token ${tokenPlaceholder} --start --auto-start` },
4748
4789
  { group: 'Robot', name: 'Simulate motion', description: 'Queue a full production trigger for the selected robot.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-trigger` },
4749
4790
  { group: 'Robot', name: 'Stop conversation', description: 'Stop the active conversation and clear queued trigger state.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-stop` },
4750
4791
  { group: 'Robot', name: 'Recover stale state', description: 'Release a robot stuck in connecting/running after a failed teardown.', command: `curl -s -X POST ${base}/api/robots/${robotId}/recover` },
@@ -4761,15 +4802,15 @@ async def dashboard():
4761
4802
 
4762
4803
  return {
4763
4804
  robots, servers, sessions, stats, connected, serverMetrics,
4764
- devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
4765
- showLiveKitConfig, livekitForm, preview, deviceInventory,
4805
+ devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandHubLan, commandHubTailscale, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
4806
+ showLiveKitConfig, livekitForm, preview, deviceInventory,
4766
4807
  voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
4767
4808
  editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
4768
4809
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
4769
4810
  toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
4770
4811
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
4771
4812
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
4772
- startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
4813
+ startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia, testSpeaker,
4773
4814
  openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
4774
4815
  openCharacterForm, saveCharacter, deleteCharacter,
4775
4816
  assignRobotCharacter, stackName, characterName,
@@ -4830,6 +4871,14 @@ async def dashboard():
4830
4871
  <button @click="commandNetwork = 'tailscale'" :class="commandNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">Tailscale</button>
4831
4872
  </div>
4832
4873
  </div>
4874
+ <div class="grid md:grid-cols-2 gap-3 mb-4">
4875
+ <label class="text-xs text-gray-400">LAN hub URL
4876
+ <input v-model="commandHubLan" class="block w-full mt-1 bg-gray-700 rounded px-3 py-2 text-sm text-white" placeholder="http://192.168.x.x:47913">
4877
+ </label>
4878
+ <label class="text-xs text-gray-400">Tailscale hub URL
4879
+ <input v-model="commandHubTailscale" class="block w-full mt-1 bg-gray-700 rounded px-3 py-2 text-sm text-white" placeholder="http://100.x.x.x:47913">
4880
+ </label>
4881
+ </div>
4833
4882
  <div class="grid md:grid-cols-2 gap-3">
4834
4883
  <div v-for="cmd in commandRows" :key="cmd.group + cmd.name" class="bg-gray-900 rounded p-3">
4835
4884
  <div class="flex items-start justify-between gap-2">
@@ -5106,7 +5155,7 @@ async def dashboard():
5106
5155
  <select v-model="deviceInventory[robot.id].selectedVideo"
5107
5156
  @change="updateDeviceMedia(robot.id)"
5108
5157
  class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5109
- <option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
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>
5110
5159
  </select>
5111
5160
  </div>
5112
5161
  <div>
@@ -5114,7 +5163,7 @@ async def dashboard():
5114
5163
  <select v-model="deviceInventory[robot.id].selectedAudio"
5115
5164
  @change="updateDeviceMedia(robot.id)"
5116
5165
  class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5117
- <option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
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>
5118
5167
  </select>
5119
5168
  </div>
5120
5169
  <div>
@@ -5122,7 +5171,7 @@ async def dashboard():
5122
5171
  <select v-model="deviceInventory[robot.id].selectedAudioOutput"
5123
5172
  @change="updateDeviceMedia(robot.id)"
5124
5173
  class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5125
- <option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
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>
5126
5175
  </select>
5127
5176
  </div>
5128
5177
  </div>
@@ -5131,12 +5180,16 @@ async def dashboard():
5131
5180
  <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>
5132
5181
  <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>
5133
5182
  </div>
5134
- <button @click="startPreview(robot)"
5183
+ <button @click="startPreview(robot)"
5135
5184
  :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
5136
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'"
5137
5186
  class="w-full px-2 py-1 rounded text-xs">
5138
- {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
5139
- </button>
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>
5140
5193
  <div class="grid grid-cols-2 gap-2">
5141
5194
  <button @click="simulateTrigger(robot)"
5142
5195
  :disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
@@ -66,7 +66,9 @@ 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
70
72
 
71
73
 
72
74
  def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
@@ -131,11 +133,13 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
131
133
  pa.terminate()
132
134
 
133
135
 
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
136
+ def _get_device_inventory() -> dict:
137
+ """Return discoverable camera and audio devices for dashboard selection."""
138
+ global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
139
+ now = time.monotonic()
140
+ if (_DEVICE_INVENTORY_CACHE is not None
141
+ and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
142
+ return _DEVICE_INVENTORY_CACHE
139
143
 
140
144
  inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
141
145
  inventory["video"].append({"id": "auto", "name": "Auto detect"})
@@ -182,7 +186,8 @@ def _get_device_inventory() -> dict:
182
186
  except Exception as e:
183
187
  logger.debug(f"audio inventory unavailable: {e}")
184
188
 
185
- _DEVICE_INVENTORY_CACHE = inventory
189
+ _DEVICE_INVENTORY_CACHE = inventory
190
+ _DEVICE_INVENTORY_CACHE_AT = now
186
191
  logger.info(
187
192
  f"device inventory: {len(inventory['video']) - 2} cameras, "
188
193
  f"{len(inventory['audio_input']) - 1} inputs, "