infinicode 2.8.93 → 2.8.95

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.
@@ -162,6 +162,8 @@ section h2 .count{color:var(--muted);}
162
162
  .fleet-attention{display:grid;gap:.36rem;max-height:154px;overflow:auto}.fleet-alert-row{display:grid;grid-template-columns:7px 1fr auto;gap:.48rem;align-items:start;padding:.36rem .42rem;border-radius:8px;background:rgba(255,255,255,.018);font-size:.61rem}.fleet-alert-row i{width:7px;height:7px;border-radius:50%;margin-top:.28rem;background:var(--warn)}.fleet-alert-row.bad i{background:var(--bad)}.fleet-alert-row.ok i{background:var(--ok)}.fleet-alert-row b{display:block;color:var(--ink);font-size:.62rem}.fleet-alert-row span{color:var(--muted);font-size:.56rem}.fleet-alert-row em{font-family:var(--mono);font-style:normal;font-size:.5rem;color:var(--muted);white-space:nowrap}
163
163
  .fleet-release{grid-column:1/-1;display:flex;align-items:center;gap:.55rem;flex-wrap:wrap;padding:.55rem .7rem}.fleet-release .version-pill{font-family:var(--mono);font-size:.56rem;border:1px solid var(--glass-border);border-radius:100px;padding:.18rem .5rem;color:var(--muted)}.fleet-release .version-pill.current{color:var(--ok);border-color:rgba(111,224,166,.28)}
164
164
  .node .fleet-card-actions{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.65rem;padding-top:.55rem;border-top:1px solid var(--glass-border)}
165
+ .fleet-live-camera{position:relative;aspect-ratio:16/9;margin:.55rem 0;border:1px solid var(--glass-border);border-radius:12px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(246,215,123,.08),transparent 44%),#050504;box-shadow:inset 0 1px 0 var(--glass-hi)}
166
+ .fleet-live-camera img{width:100%;height:100%;display:block;object-fit:cover;background:#050504}.fleet-live-camera .state{position:absolute;left:.5rem;bottom:.45rem;padding:.18rem .4rem;border-radius:100px;background:rgba(3,3,2,.78);border:1px solid var(--glass-border);font-family:var(--mono);font-size:.5rem;color:var(--warn);backdrop-filter:blur(8px)}.fleet-live-camera.live .state{color:var(--ok)}.fleet-live-camera.failed .state{color:var(--bad)}
165
167
  @media(max-width:1150px){.fleet-kpis{grid-template-columns:repeat(3,1fr)}.fleet-insights{grid-template-columns:1fr 1fr}.fleet-insights .fleet-panel:last-of-type{grid-column:1/-1}}
166
168
  @media(max-width:720px){.fleet-hero{align-items:flex-start;flex-direction:column}.fleet-hero-actions{justify-content:flex-start}.fleet-kpis{grid-template-columns:repeat(2,1fr)}.fleet-insights{display:block}.fleet-panel{margin-bottom:.65rem}.fleet-readiness{grid-template-columns:90px 1fr}.fleet-donut{width:86px;height:86px}}
167
169
 
@@ -1484,6 +1486,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
1484
1486
  }
1485
1487
  function renderFleet(nodes){
1486
1488
  var el = $('fleet');
1489
+ // Keep existing MJPEG elements alive while telemetry cards are rebuilt.
1490
+ // Replacing an <img> tears down its long-lived stream and made cameras
1491
+ // appear to reconnect on every fleet poll.
1492
+ var preservedCameras={};
1493
+ if(el){Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(img){var key=img.getAttribute('data-fleet-camera');if(key)preservedCameras[key]=img;});}
1487
1494
  renderFleetOperations(nodes||[],'fleet');
1488
1495
  if(!nodes || !nodes.length){ el.innerHTML = '<div class="empty">No nodes reporting yet. Start peers with <span class="mono">infinicode serve</span> or <span class="mono">robopark setup</span>.</div>'; return; }
1489
1496
  // connected first, then robots/satellites above hubs so the fleet reads robot-first
@@ -1510,13 +1517,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
1510
1517
  var avatar=rpAvatarFor(tele&&(tele.character_name||tele.character_id)||n.displayName);
1511
1518
  var readiness=tele&&(tele.readiness_code||tele.status)||(!up?'offline':'mesh online');
1512
1519
  var hb=tele&&tele.heartbeat_age_seconds!=null?tele.heartbeat_age_seconds:null;
1513
- var site = n.site?(' · '+esc(n.site)):'';
1520
+ var site = n.site?(' · '+esc(n.site)):'';
1521
+ var cameraRef=n.displayName||n.name||n.nodeId||n.id;
1514
1522
 
1515
1523
  html += '<div class="node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'" tabindex="0" role="button" data-id="'+esc(n.nodeId)+'" data-name="'+esc(n.displayName)+'">'
1516
1524
  + '<div class="top"><span class="name"><span class="statdot '+(up?'on':'off')+'"></span>'+esc(n.displayName)+(isSelf?' <span class="selfbadge">this</span>':'')+'</span><span class="role">'+esc(n.role)+'</span></div>'
1517
1525
  + '<div class="meta">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>'
1518
1526
  + (avatar?('<div class="node-hero"><img class="node-avatar" alt="'+esc(n.displayName)+'" src="'+esc(avatar+QS)+'"><div class="node-health"><b>'+esc(String(readiness).replace(/_/g,' '))+'</b><br>'
1519
1527
  +(tele?('sessions '+esc(tele.sessions_total||0)+' / triggers '+esc(tele.trigger_count||0)+'<br>latency '+esc(tele.avg_latency_ms!=null?Math.round(tele.avg_latency_ms)+'ms':'n/a')+' / drops '+esc(tele.drop_rate!=null?Math.round(tele.drop_rate*100)+'%':'n/a')+'<br>heartbeat '+esc(hb!=null?hb+'s ago':'unavailable')):'scheduler telemetry pending')+'</div></div>'):'');
1528
+
1529
+ if((tele||String(n.role||'').toLowerCase()==='satellite'||String(n.role||'').toLowerCase()==='robot')&&cameraRef){
1530
+ html += '<div class="fleet-live-camera" data-fleet-camera-host="'+esc(cameraRef)+'"><img alt="Live camera for '+esc(n.displayName)+'" data-fleet-camera="'+esc(cameraRef)+'"><span class="state">'+(up?'connecting camera':'robot offline')+'</span></div>';
1531
+ }
1520
1532
 
1521
1533
  if(up){
1522
1534
  html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))
@@ -1536,7 +1548,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
1536
1548
  html += (capHtml?('<div class="caps">'+capHtml+'</div>'):'')
1537
1549
  + ((tele||String(n.role||'').toLowerCase()==='satellite')?('<div class="fleet-card-actions"><button class="rp-btn primary" data-fleet-action="camera" data-robot="'+esc(n.displayName)+'">Live camera</button><button class="rp-btn" data-fleet-action="sessions" data-robot="'+esc(n.displayName)+'">Sessions</button><button class="rp-btn" data-fleet-action="incidents" data-robot="'+esc(n.displayName)+'">Health</button>'+(tele&&(tele.character_id||tele.character_preset_id)?'<button class="rp-btn" data-config-edit-character="'+esc(tele.character_id||tele.character_preset_id)+'">Edit character</button>':'')+(tele&&tele.voice_stack_id?'<button class="rp-btn" data-config-edit-stack="'+esc(tele.voice_stack_id)+'">Edit voice</button>':'')+'</div>'):'')+'</div>';
1538
1550
  }
1539
- el.innerHTML = html;
1551
+ el.innerHTML = html;
1552
+ Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(placeholder){
1553
+ var key=placeholder.getAttribute('data-fleet-camera'),old=preservedCameras[key];
1554
+ if(old){placeholder.parentNode.replaceChild(old,placeholder);placeholder=old;}
1555
+ var host=placeholder.closest('[data-fleet-camera-host]'),state=host&&host.querySelector('.state');
1556
+ function connect(){
1557
+ if(!placeholder.isConnected)return;
1558
+ placeholder.src='/fed/media/robots/'+encodeURIComponent(key)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
1559
+ }
1560
+ placeholder.onload=function(){if(host){host.classList.add('live');host.classList.remove('failed');}if(state)state.textContent='always-on live';};
1561
+ placeholder.onerror=function(){if(host){host.classList.remove('live');host.classList.add('failed');}if(state)state.textContent='reconnecting camera';placeholder.removeAttribute('src');setTimeout(connect,4000);};
1562
+ if(!placeholder.getAttribute('src'))connect();
1563
+ else if(host){host.classList.add('live');if(state)state.textContent='always-on live';}
1564
+ });
1540
1565
  var cards = el.querySelectorAll('.node');
1541
1566
  for(var j=0;j<cards.length;j++){
1542
1567
  (function(card){
@@ -6161,8 +6186,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
6161
6186
  }).catch(function(e){ var pm=$('pk-prodmode'); if(pm) pm.checked=!on; showOut('production mode ('+id+'): '+e.message); });
6162
6187
  }
6163
6188
  // ── on-demand operator preview: open the robot's cam without a scene-detection session ──
6164
- function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?' Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
6165
- function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
6189
+ function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent=' Reconnect camera'; b.classList.remove('danger'); } }
6190
+ function goLive(n){ stopVision(); tryVisionOverlay(n,true); }
6166
6191
  function startPreview(n){
6167
6192
  previewOn=true; updateGoLive();
6168
6193
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
@@ -6189,9 +6214,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
6189
6214
  previewOn=false; updateGoLive();
6190
6215
  if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
6191
6216
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
6192
- stopVision();
6193
6217
  var directNote=$('pk-golivenote'); if(directNote) directNote.textContent='';
6194
- idleCam('idle');
6218
+ tryVisionOverlay(n,true);
6195
6219
  return;
6196
6220
  previewOn=false; updateGoLive();
6197
6221
  if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
@@ -6582,39 +6606,42 @@ button.act:disabled{opacity:0.5;cursor:default;}
6582
6606
  // Long-poll the result endpoint. The scheduler blocks up to 8s
6583
6607
  // before returning timed_out: true; we just respect that and
6584
6608
  // give the operator a clear "still waiting" line in the output.
6585
- function _shellWaitForResult(robotId, requestId, deviceId, service, label){
6609
+ function _shellWaitForResult(robotId, requestId, deviceId, service, label){
6586
6610
  var url = '/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/wait/'+encodeURIComponent(requestId)+'?timeout=8';
6587
6611
  var pollOnce = function(){
6588
6612
  return getJson(url).then(function(r){
6589
- if(r && r.completed){
6590
- var result = r.result || {};
6591
- _renderShellResult(robotId, result, label);
6592
- return;
6613
+ if(r && r.completed){
6614
+ var result = r.result || {};
6615
+ _renderShellResult(robotId, result, label);
6616
+ return result;
6593
6617
  }
6594
6618
  // Not done yet — fall through to manual polling
6595
6619
  throw new Error('not_ready');
6596
6620
  });
6597
6621
  };
6598
- return pollOnce().catch(function(){
6622
+ return pollOnce().catch(function(){
6599
6623
  // Manual poll loop (covers the case where /wait timed out but
6600
6624
  // the robot is still chewing). Cap at 12s of fallback polling.
6601
6625
  var deadline = Date.now() + 12000;
6602
- var tick = function(){
6603
- if(Date.now() > deadline){
6604
- _setShellStatus(robotId, '⚠ '+label+' did not complete in 20s — robot may be offline', 'error');
6605
- _shellAppend(robotId, 'status', '✗ '+label+' did not complete in 20s');
6606
- return;
6607
- }
6608
- getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/result/'+encodeURIComponent(requestId))
6609
- .then(function(r){
6610
- if(r && r.completed){ _renderShellResult(robotId, r.result || {}, label); return; }
6611
- setTimeout(tick, 500);
6612
- })
6613
- .catch(function(){ setTimeout(tick, 800); });
6614
- };
6615
- tick();
6616
- });
6617
- }
6626
+ return new Promise(function(resolve,reject){
6627
+ var tick = function(){
6628
+ if(Date.now() > deadline){
6629
+ _setShellStatus(robotId, ' '+label+' did not complete in 20s — robot may be offline', 'error');
6630
+ _shellAppend(robotId, 'status', '✗ '+label+' did not complete in 20s');
6631
+ reject(new Error(label+' did not complete in 20s'));
6632
+ return;
6633
+ }
6634
+ getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/result/'+encodeURIComponent(requestId))
6635
+ .then(function(r){
6636
+ if(r && r.completed){var result=r.result||{};_renderShellResult(robotId,result,label);resolve(result);return;}
6637
+ setTimeout(tick,500);
6638
+ })
6639
+ .catch(function(){setTimeout(tick,800);});
6640
+ };
6641
+ tick();
6642
+ });
6643
+ });
6644
+ }
6618
6645
  function _renderShellResult(robotId, result, label){
6619
6646
  if(!result || !result.ok){
6620
6647
  var err = (result && result.error) || '(no response)';
@@ -6688,11 +6715,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
6688
6715
  +'<img id="pk-vision-img" style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0;display:none;border-radius:inherit" alt="vision feed"/>'
6689
6716
  +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
6690
6717
  +'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
6691
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><button class="rp-btn primary" id="pk-camera-fullscreen">Fullscreen operations view</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
6718
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">↻ Reconnect camera</button><button class="rp-btn primary" id="pk-camera-fullscreen">Fullscreen operations view</button><span class="pk-note" id="pk-golivenote" style="margin:0">always-on RoboVision relay</span></div>'
6692
6719
  +'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
6693
6720
  + sect('Remote production test','<div id="pk-pipeline-summary" class="pk-pipeline-summary">Checking robot, camera, scheduler and LiveKit...</div>'
6694
- + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
6695
- + '<div class="pk-note">Starts the camera, sends the same motion trigger used in production, and follows every reported stage remotely.</div>'
6721
+ + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-unattended-test">Run unattended readiness</button><button class="rp-btn" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
6722
+ + '<div class="pk-note" id="pk-unattended-result">Unattended readiness runs an acoustic speaker/mic round-trip, triggers the production motion path, and requires greeting playback on the robot.</div>'
6696
6723
  + '<div class="pk-pipeline" id="pk-pipeline"></div>')
6697
6724
  + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div><div class="pk-ctl" style="margin-top:.5rem"><button class="rp-btn" id="pk-mic-listen">Listen in browser</button><label class="pk-note" style="display:flex;align-items:center;gap:.35rem;margin:0">volume <input id="pk-mic-volume" type="range" min="0" max="100" value="80" style="width:90px"></label></div><div class="pk-note" id="pk-mic-listen-note">Muted by default. Use headphones to prevent acoustic feedback.</div>')
6698
6725
  + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">TTS plays on the robot speaker — use “Test speaker” below to round-trip a tone and confirm the audio path</div>'
@@ -6748,9 +6775,6 @@ button.act:disabled{opacity:0.5;cursor:default;}
6748
6775
  var gl=$('pk-golive');
6749
6776
  if(gl){
6750
6777
  gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
6751
- var vl=document.createElement('button'); vl.className='rp-btn'; vl.id='pk-vision-live'; vl.textContent='Open RoboVision feed';
6752
- vl.addEventListener('click', function(){ tryVisionOverlay(n, true); if(!visionOn) showOut('RoboVision feed unavailable: robot has no reachable LAN/Tailscale address'); });
6753
- gl.after(vl);
6754
6778
  }
6755
6779
  var fullCam=$('pk-camera-fullscreen'); if(fullCam) fullCam.addEventListener('click',function(){ openCameraOperations(n); });
6756
6780
  var editCharacter=$('pk-edit-character'); if(editCharacter) editCharacter.addEventListener('click',function(){var id=$('pk-eff-char').value;if(id)openCharacterManager(id);else showOut('Choose a character to edit');});
@@ -6766,6 +6790,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6766
6790
  var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
6767
6791
  var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
6768
6792
  var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });
6793
+ var unattended=$('pk-unattended-test'); if(unattended) unattended.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runUnattendedReadiness(n); });
6769
6794
  var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });
6770
6795
  pollPipeline(n);
6771
6796
  if(pipelineTimer) clearInterval(pipelineTimer);
@@ -6842,6 +6867,53 @@ button.act:disabled{opacity:0.5;cursor:default;}
6842
6867
  showOut('conversation stopped for '+(n.label||n.name)); pollPipeline(n); startCam(n);
6843
6868
  }).catch(function(e){ showOut('stop test: '+e.message); });
6844
6869
  }
6870
+ function runUnattendedReadiness(n){
6871
+ var button=$('pk-unattended-test'),resultEl=$('pk-unattended-result'),robotId=schedId(n),device=schedulerDevice(n)||{};
6872
+ if(!device.id){showOut('Robot has no scheduler device identity');return;}
6873
+ if(button){button.disabled=true;button.textContent='Testing hardware...';}
6874
+ function report(text,bad){if(resultEl){resultEl.textContent=text;resultEl.style.color=bad?'var(--bad)':'var(--warn)';}}
6875
+ function delay(ms){return new Promise(function(resolve){setTimeout(resolve,ms);});}
6876
+ function status(){return getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-status'+QS);}
6877
+ function waitForGreeting(deadline){
6878
+ return status().then(function(data){
6879
+ renderPipeline(data);
6880
+ var sid=data.latest_session&&data.latest_session.id,seen={};
6881
+ (data.events||[]).forEach(function(ev){if(!ev.session_id||!sid||ev.session_id===sid)seen[ev.stage]=ev;});
6882
+ var required=['scheduler_session','livekit_join','camera_published','microphone_published','tts_subscribed','playback_started'];
6883
+ var missing=required.filter(function(stage){return !seen[stage]||seen[stage].status!=='ok';});
6884
+ if(!missing.length)return {data:data,required:required};
6885
+ if(Date.now()>deadline)throw new Error('production greeting timed out; missing '+missing.join(', '));
6886
+ report('Pipeline running · waiting for '+missing.join(', '));
6887
+ return delay(1500).then(function(){return waitForGreeting(deadline);});
6888
+ });
6889
+ }
6890
+ var inv=device.device_inventory||{},media=inv.media_health||{};
6891
+ var cameraProblems=[];
6892
+ if(media.camera_access===false)cameraProblems.push('camera permission denied');
6893
+ if(media.camera_stalled===true)cameraProblems.push('camera worker stalled');
6894
+ if(media.camera_frame_age_seconds!=null&&Number(media.camera_frame_age_seconds)>15)cameraProblems.push('camera frame '+Math.round(Number(media.camera_frame_age_seconds))+'s old');
6895
+ if(cameraProblems.length){report('FAILED · '+cameraProblems.join(' · '),true);if(button){button.disabled=false;button.textContent='Run unattended readiness';}return;}
6896
+ report('Camera healthy · running physical speaker/mic round-trip');
6897
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{
6898
+ mode:'tone',playback_only:false,frequency:1000,duration:0.8,amplitude:0.6,threshold_db:-30,
6899
+ output:'1',input:'2',output_name:'USB Audio Device: -',input_name:'Usb Audio Device: USB Audio'
6900
+ }).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','speaker-test');})
6901
+ .then(function(acoustic){
6902
+ if(!acoustic||!acoustic.ok||acoustic.pass!==true)throw new Error((acoustic&&acoustic.error)||(acoustic&&acoustic.diagnostic)||'speaker/mic acoustic round-trip failed');
6903
+ report('Acoustic path passed · starting production motion and greeting');
6904
+ if(button)button.textContent='Testing production loop...';
6905
+ var enable=device.production_mode?Promise.resolve():rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){device.production_mode=true;});
6906
+ return enable.then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-test/start',{});});
6907
+ })
6908
+ .then(function(queued){if(!queued||!queued.queued)throw new Error(queued&&queued.reason||'production trigger was not queued');return waitForGreeting(Date.now()+45000);})
6909
+ .then(function(done){
6910
+ report('PASSED · camera fresh · acoustic speaker/mic path confirmed · motion → scheduler → LiveKit → mic/camera → TTS → robot playback confirmed');
6911
+ if(resultEl)resultEl.style.color='var(--ok)';
6912
+ showOut('unattended production readiness passed for '+(n.label||n.name));
6913
+ })
6914
+ .catch(function(e){report('FAILED · '+e.message,true);showOut('unattended readiness: '+e.message);})
6915
+ .then(function(){if(button){button.disabled=false;button.textContent='Run unattended readiness';}});
6916
+ }
6845
6917
  function refreshDrawer(){
6846
6918
  var n=drawerId?byId[drawerId]:null; if(!n) return;
6847
6919
  if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){
@@ -83,6 +83,10 @@ export async function roboparkRobotRuntime(opts) {
83
83
  const children = [];
84
84
  let stopping = false;
85
85
  let recycling = false;
86
+ const requestedVideo = String(opts.videoDevice ?? '').trim();
87
+ const hardwareVideo = !requestedVideo || ['auto', 'default', 'first'].includes(requestedVideo.toLowerCase())
88
+ ? '/dev/video0'
89
+ : requestedVideo;
86
90
  const infinicode = await resolveInfinicodeBin();
87
91
  if (!infinicode)
88
92
  throw new Error('could not resolve infinicode CLI');
@@ -100,14 +104,15 @@ export async function roboparkRobotRuntime(opts) {
100
104
  args: [cli, 'vision-agent', '--foreground', '--port', '5000',
101
105
  '--motion-webhook-url', 'http://127.0.0.1:5057/'],
102
106
  env: {
103
- ROBOPARK_CAMERA_DEVICE: opts.videoDevice ?? '/dev/robopark-camera',
107
+ ROBOPARK_CAMERA_DEVICE: hardwareVideo,
104
108
  },
105
109
  });
106
110
  }
107
111
  const previewArgs = [cli, 'preview-agent', '--foreground',
108
112
  '--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
109
- '--video-device', opts.videoDevice ?? '/dev/video0',
113
+ '--video-device', hardwareVideo,
110
114
  '--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio (hw:3,0)',
115
+ '--robovision-url', 'http://127.0.0.1:5000',
111
116
  '--save-config'];
112
117
  if (opts.enrollmentToken)
113
118
  previewArgs.push('--enrollment-token', opts.enrollmentToken);
@@ -127,6 +132,9 @@ export async function roboparkRobotRuntime(opts) {
127
132
  ? 'http://127.0.0.1:5000/api/media/inventory'
128
133
  : '',
129
134
  ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
135
+ // Motion remains enabled, but it reads RoboVision's shared stream. Only
136
+ // RoboVision may open the physical V4L2 device.
137
+ LOCAL_CAMERA_MOTION: 'true',
130
138
  // Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
131
139
  // from these USB product labels on every inventory refresh.
132
140
  ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
@@ -142,3 +142,21 @@ Required result: `playback_started ok`.
142
142
  status and recycles media after three consecutive unhealthy checks.
143
143
  - Park media operations display camera/audio permission, camera worker state,
144
144
  and latest frame age from `device_inventory.media_health`.
145
+ - Camera visibility is independent of conversation sessions. Fleet cards and
146
+ the robot drawer use the RoboVision mesh MJPEG relay continuously and keep
147
+ stream elements alive across telemetry rerenders.
148
+ - The dashboard unattended readiness test requires camera health, an acoustic
149
+ speaker-to-microphone round-trip, scheduler allocation, LiveKit join, camera
150
+ and microphone publication, TTS subscription, and first robot playback.
151
+ - An unattended readiness pass does not claim an STT/LLM turn occurred. That
152
+ semantic turn still requires injected speech or a person near the robot.
153
+
154
+ ## Camera Ownership Contract (2.8.95+)
155
+
156
+ - RoboVision is the only process permitted to open the physical V4L2 camera.
157
+ - Dashboard, motion sampling, and conversation video consume RoboVision's
158
+ shared MJPEG stream; they never open `/dev/video0` independently.
159
+ - Local camera motion remains enabled by default. Production mode controls
160
+ whether motion creates a conversation, not whether the camera worker runs.
161
+ - `auto`, `default`, and `first` resolve to `/dev/robopark-camera` when present,
162
+ otherwise `/dev/video0`; those literal strings are never passed to OpenCV.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.93",
3
+ "version": "2.8.95",
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",
@@ -486,7 +486,7 @@ class PreviewAgent:
486
486
  # to its hardware name before opening any playback stream.
487
487
  self.audio_playback_device = self.audio_output_device
488
488
  self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
489
- self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
489
+ self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
490
490
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
491
491
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
492
492
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -522,7 +522,8 @@ class PreviewAgent:
522
522
  self._speaker_operation_lock = asyncio.Lock()
523
523
  self._vision_hard_deadline: float = 0.0
524
524
  self._vision_last_activity: float = 0.0
525
- self.production_mode = False
525
+ self.production_mode = False
526
+ self._robovision_motion_state: Optional[bool] = None
526
527
  self._remote_session_ended = False
527
528
  self._last_device_config_poll = 0.0
528
529
  self._motion_reference = None
@@ -632,9 +633,13 @@ class PreviewAgent:
632
633
  if capture is not None:
633
634
  await asyncio.to_thread(capture.stop)
634
635
 
635
- async def _motion_loop(self) -> None:
636
- """Own the camera while idle and detect motion without a second process."""
637
- while not self._shutdown.is_set():
636
+ async def _motion_loop(self) -> None:
637
+ """Detect motion from RoboVision's shared stream without owning V4L2."""
638
+ while not self._shutdown.is_set():
639
+ if not self.production_mode:
640
+ await self._stop_motion_capture()
641
+ await asyncio.sleep(0.5)
642
+ continue
638
643
  if self._vision_session_id or self._current_state.active:
639
644
  await self._stop_motion_capture()
640
645
  await asyncio.sleep(0.25)
@@ -643,16 +648,17 @@ class PreviewAgent:
643
648
  async with self._motion_capture_lock:
644
649
  if self._motion_capture is None:
645
650
  self._motion_capture = await asyncio.to_thread(
646
- create_video_capture,
647
- self.video_device,
648
- self.width,
649
- self.height,
650
- self.fps,
651
- )
651
+ create_video_capture,
652
+ self.video_device,
653
+ self.width,
654
+ self.height,
655
+ self.fps,
656
+ self.robovision_url,
657
+ )
652
658
  self._motion_reference = None
653
659
  if self._motion_capture is None:
654
660
  continue
655
- logger.info("motion camera opened by preview agent")
661
+ logger.info("motion sampler connected to RoboVision shared stream")
656
662
  frame = await asyncio.to_thread(self._motion_capture.read)
657
663
  if frame is not None:
658
664
  self._detect_motion(frame)
@@ -921,6 +927,20 @@ class PreviewAgent:
921
927
  self._next_mesh_bootstrap = 0.0
922
928
  if production_mode is not None:
923
929
  self.production_mode = production_mode
930
+ # Exactly one motion detector is active. With the default
931
+ # local sampler enabled, preview reads RoboVision's shared
932
+ # MJPEG stream and RoboVision only owns capture/encoding.
933
+ robovision_motion = bool(production_mode and not self.local_camera_motion)
934
+ if self._robovision_motion_state != robovision_motion:
935
+ try:
936
+ response = await self._session.post(
937
+ f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
938
+ json={"enabled": robovision_motion}, timeout=1.5,
939
+ )
940
+ response.raise_for_status()
941
+ self._robovision_motion_state = robovision_motion
942
+ except Exception as exc:
943
+ logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
924
944
  await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
925
945
  inventory = _get_device_inventory()
926
946
  real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
@@ -2128,7 +2148,7 @@ def main() -> None:
2128
2148
  "audio_device": args.audio_device,
2129
2149
  "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
2130
2150
  "use_robovision_camera": bool(args.robovision_url) or os.getenv(
2131
- "ROBOVISION_CAMERA", ""
2151
+ "ROBOVISION_CAMERA", "true"
2132
2152
  ).lower() in ("1", "true", "yes", "on"),
2133
2153
  "video_width": args.width,
2134
2154
  "video_height": args.height,
@@ -17,8 +17,19 @@ CORS(app)
17
17
 
18
18
  # Camera management. Production installs create /dev/robopark-camera from
19
19
  # the primary USB capture interface, avoiding /dev/videoN renumbering.
20
+ def _normalize_camera_device(value):
21
+ configured = str(value or "").strip()
22
+ if configured.lower() in ("", "auto", "default", "first"):
23
+ if sys.platform.startswith("linux"):
24
+ return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
25
+ return 0
26
+ if configured.isdigit():
27
+ return int(configured)
28
+ return configured
29
+
30
+
20
31
  _configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
21
- current_camera_index = _configured_camera or ("/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else 0)
32
+ current_camera_index = _normalize_camera_device(_configured_camera)
22
33
  camera = None
23
34
  camera_lock = threading.Lock()
24
35
  frame_condition = threading.Condition()
@@ -41,9 +52,17 @@ def _open_camera(index):
41
52
  # webcam — isOpened() then stays False forever and callers retry in an
42
53
  # infinite doomed loop. Force DirectShow, the backend that actually
43
54
  # enumerates standard Windows webcams.
44
- if sys.platform == 'win32':
45
- return cv2.VideoCapture(index, cv2.CAP_DSHOW)
46
- return cv2.VideoCapture(index)
55
+ if sys.platform == 'win32':
56
+ return cv2.VideoCapture(index, cv2.CAP_DSHOW)
57
+ if sys.platform.startswith('linux'):
58
+ cap = cv2.VideoCapture(index, cv2.CAP_V4L2)
59
+ if cap.isOpened():
60
+ cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
61
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
62
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
63
+ cap.set(cv2.CAP_PROP_FPS, 15)
64
+ return cap
65
+ return cv2.VideoCapture(index)
47
66
 
48
67
 
49
68
  def get_camera():
@@ -469,7 +488,7 @@ def switch_camera():
469
488
  with camera_lock:
470
489
  if camera:
471
490
  camera.release()
472
- current_camera_index = new_index
491
+ current_camera_index = _normalize_camera_device(new_index)
473
492
  camera = None
474
493
 
475
494
  return jsonify({"status": "ok", "camera_index": current_camera_index})
@@ -531,7 +550,7 @@ def media_config():
531
550
  switch_camera_value = value
532
551
  if camera:
533
552
  camera.release()
534
- current_camera_index = int(switch_camera_value) if switch_camera_value.isdigit() else switch_camera_value
553
+ current_camera_index = _normalize_camera_device(switch_camera_value)
535
554
  camera = None
536
555
  if "audio_device" in data:
537
556
  audio_input_device = str(data["audio_device"])