infinicode 2.8.92 → 2.8.94

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')){
@@ -6888,7 +6960,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6888
6960
  var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class="metric"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class="metric"><b>'+esc(active?'in session':'standing by')+'</b><span>conversation</span></div><div class="metric"><b>'+(row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'--')+'</b><span>average latency</span></div><div class="metric"><b>'+(row.drop_rate!=null?Math.round(row.drop_rate*100)+'%':'--')+'</b><span>session drops</span></div>';
6889
6961
  var insights=$('pk-camera-ops-insights');if(insights)insights.innerHTML='<span>Camera relay</span><b class="'+(cameraLive?'ok':'bad')+'">'+(cameraLive?'LIVE':'CONNECTING')+'</b><span>Mesh node</span><b class="'+(n.connected?'ok':'bad')+'">'+(n.connected?'connected':'offline')+'</b><span>Production mode</span><b class="'+(device.production_mode?'ok':'')+'">'+(device.production_mode?'armed':'disabled')+'</b><span>Readiness</span><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>Heartbeat</span><b>'+(row.heartbeat_age_seconds!=null?Math.round(row.heartbeat_age_seconds)+'s ago':'--')+'</b><span>Current room</span><b>'+esc(String(row.current_session_id||row.active_session_id||'none').slice(0,24))+'</b><span>Triggers</span><b>'+esc(row.trigger_count!=null?row.trigger_count:0)+'</b><span>Sessions</span><b>'+esc(row.sessions_total!=null?row.sessions_total:0)+'</b>';
6890
6962
  var stages=[['Robot online',!!n.connected],['Camera streaming',cameraLive],['Motion detector armed',!!device.production_mode],['Scheduler session',active],['Voice pipeline',readiness==='in_session'||active]],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s){return '<div><i class="'+(s[1]?'ok':'warn')+'"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?'ready':'waiting')+'</b></div>';}).join('');
6891
- var inv=device.device_inventory||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
6963
+ var inv=device.device_inventory||{},mh=inv.media_health||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Camera permission</span><b class="'+(mh.camera_access===false?'bad':'ok')+'">'+(mh.camera_access===false?'DENIED':mh.camera_access===true?'granted':'--')+'</b><span>Audio permission</span><b class="'+(mh.audio_access===false?'bad':'ok')+'">'+(mh.audio_access===false?'DENIED':mh.audio_access===true?'granted':'--')+'</b><span>Camera worker</span><b class="'+(mh.camera_stalled?'bad':'ok')+'">'+(mh.camera_stalled?'STALLED':mh.camera_worker?'streaming':'--')+'</b><span>Frame age</span><b>'+(mh.camera_frame_age_seconds!=null?Number(mh.camera_frame_age_seconds).toFixed(1)+'s':'--')+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
6892
6964
  var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}
6893
6965
  }
6894
6966
  function reloadCameraOperations(){
@@ -65,19 +65,56 @@ async function registerSystemd(service) {
65
65
  const envLines = Object.entries(service.env ?? {})
66
66
  .map(([k, v]) => `Environment="${k}=${v.replace(/"/g, '\\"')}"`)
67
67
  .join('\n');
68
- const unit = `[Unit]
69
- Description=RoboPark ${service.role} for ${service.name}
70
- After=network-online.target
71
- Wants=network-online.target
72
-
73
- [Service]
74
- Type=simple
75
- ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
68
+ const mediaService = service.role === 'robot-runtime' || service.role === 'robot-preview-agent' || service.role === 'robot-vision-agent';
69
+ const mediaGroups = mediaService
70
+ ? ['audio', 'video', 'render', 'input', 'plugdev'].filter(group => spawnSync('getent', ['group', group], { stdio: 'ignore' }).status === 0)
71
+ : [];
72
+ const mediaPolicy = mediaService ? [
73
+ mediaGroups.length ? `SupplementaryGroups=${mediaGroups.join(' ')}` : '',
74
+ 'UMask=0007',
75
+ 'LimitNOFILE=65536',
76
+ 'TasksMax=512',
77
+ 'OOMScoreAdjust=-500',
78
+ ].filter(Boolean).join('\n') : '';
79
+ let mediaRulesInstalled = false;
80
+ if (mediaService) {
81
+ const rulesPath = '/etc/udev/rules.d/70-robopark-media.rules';
82
+ const rules = [
83
+ '# Managed by RoboPark. Shared hardware profile for production robots.',
84
+ 'SUBSYSTEM=="video4linux", GROUP="video", MODE="0660"',
85
+ 'SUBSYSTEM=="video4linux", ENV{ID_BUS}=="usb", ATTR{index}=="0", SYMLINK+="robopark-camera"',
86
+ 'SUBSYSTEM=="sound", GROUP="audio", MODE="0660"',
87
+ '',
88
+ ].join('\n');
89
+ try {
90
+ writeFileSync(rulesPath, rules, 'utf8');
91
+ spawnSync('udevadm', ['control', '--reload-rules'], { stdio: 'ignore' });
92
+ spawnSync('udevadm', ['trigger', '--subsystem-match=video4linux'], { stdio: 'ignore' });
93
+ spawnSync('udevadm', ['trigger', '--subsystem-match=sound'], { stdio: 'ignore' });
94
+ mediaRulesInstalled = true;
95
+ }
96
+ catch {
97
+ // The explicit systemd groups below still cover standard distro rules.
98
+ }
99
+ }
100
+ const unit = `[Unit]
101
+ Description=RoboPark ${service.role} for ${service.name}
102
+ After=network-online.target sound.target
103
+ Wants=network-online.target sound.target
104
+ StartLimitIntervalSec=120
105
+ StartLimitBurst=20
106
+
107
+ [Service]
108
+ Type=simple
109
+ ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
110
  WorkingDirectory=${service.workingDir ?? homedir()}
77
111
  Restart=always
78
- RestartSec=5
112
+ RestartSec=3
79
113
  KillMode=control-group
80
- TimeoutStopSec=10
114
+ TimeoutStopSec=20
115
+ SendSIGKILL=yes
116
+ FinalKillSignal=SIGKILL
117
+ ${mediaPolicy}
81
118
  ${envLines}
82
119
 
83
120
  [Install]
@@ -93,7 +130,10 @@ WantedBy=multi-user.target
93
130
  if (enabled.status !== 0) {
94
131
  return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
95
132
  }
96
- return { ok: true, message: `enabled and started systemd unit ${unitName}` };
133
+ return {
134
+ ok: true,
135
+ message: `enabled and started systemd unit ${unitName}${mediaRulesInstalled ? ' with persistent media permissions' : ''}`,
136
+ };
97
137
  }
98
138
  catch (e) {
99
139
  return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
@@ -15,7 +15,9 @@ import { fileURLToPath } from 'node:url';
15
15
  import chalk from 'chalk';
16
16
  import { resolveInfinicodeBin } from './serve.js';
17
17
  const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
18
+ const VISION_STATUS_URL = 'http://127.0.0.1:5000/api/camera/status';
18
19
  const RESTART_DELAY_MS = 5_000;
20
+ const MEDIA_WATCHDOG_INTERVAL_MS = 10_000;
19
21
  function resetStaleEnrollment() {
20
22
  const configDir = join(homedir(), '.robopark');
21
23
  const tokenPath = join(configDir, 'device_token');
@@ -97,6 +99,9 @@ export async function roboparkRobotRuntime(opts) {
97
99
  command: process.execPath,
98
100
  args: [cli, 'vision-agent', '--foreground', '--port', '5000',
99
101
  '--motion-webhook-url', 'http://127.0.0.1:5057/'],
102
+ env: {
103
+ ROBOPARK_CAMERA_DEVICE: opts.videoDevice ?? '/dev/robopark-camera',
104
+ },
100
105
  });
101
106
  }
102
107
  const previewArgs = [cli, 'preview-agent', '--foreground',
@@ -122,6 +127,10 @@ export async function roboparkRobotRuntime(opts) {
122
127
  ? 'http://127.0.0.1:5000/api/media/inventory'
123
128
  : '',
124
129
  ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
130
+ // Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
131
+ // from these USB product labels on every inventory refresh.
132
+ ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
133
+ ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
125
134
  // Inventory changes should reach the Control Center promptly.
126
135
  HEARTBEAT_INTERVAL: '5',
127
136
  },
@@ -182,6 +191,43 @@ export async function roboparkRobotRuntime(opts) {
182
191
  signalChild(entry, 'SIGTERM');
183
192
  });
184
193
  };
194
+ let visionFailureCount = 0;
195
+ let visionRecoveryInFlight = false;
196
+ const monitorVision = async () => {
197
+ if (stopping || recycling || visionRecoveryInFlight || opts.vision === false)
198
+ return;
199
+ const vision = children.find(entry => entry.label === 'vision');
200
+ if (!vision?.child)
201
+ return;
202
+ let healthy = false;
203
+ try {
204
+ const response = await fetch(VISION_STATUS_URL, { signal: AbortSignal.timeout(2_000) });
205
+ if (response.ok) {
206
+ const status = await response.json();
207
+ const frameTooOld = status.worker_started === true
208
+ && typeof status.last_frame_age_seconds === 'number'
209
+ && status.last_frame_age_seconds > 15;
210
+ healthy = !status.read_stalled && !frameTooOld;
211
+ }
212
+ }
213
+ catch {
214
+ healthy = false;
215
+ }
216
+ visionFailureCount = healthy ? 0 : visionFailureCount + 1;
217
+ if (visionFailureCount < 3)
218
+ return;
219
+ visionRecoveryInFlight = true;
220
+ console.error(chalk.yellow(' media watchdog: RoboVision unhealthy for 30s; recycling camera/audio pair'));
221
+ try {
222
+ await stopChild(vision);
223
+ if (!stopping)
224
+ start(vision);
225
+ visionFailureCount = 0;
226
+ }
227
+ finally {
228
+ visionRecoveryInFlight = false;
229
+ }
230
+ };
185
231
  const recycleAfterMeshUpdate = async () => {
186
232
  if (stopping || recycling)
187
233
  return;
@@ -231,8 +277,10 @@ export async function roboparkRobotRuntime(opts) {
231
277
  : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
232
278
  });
233
279
  }
280
+ const mediaWatchdog = setInterval(() => { void monitorVision(); }, MEDIA_WATCHDOG_INTERVAL_MS);
234
281
  const shutdown = () => {
235
282
  stopping = true;
283
+ clearInterval(mediaWatchdog);
236
284
  for (const entry of [...children, preview])
237
285
  signalChild(entry, 'SIGTERM');
238
286
  };
@@ -196,6 +196,9 @@ async function setupRobot(config, opts, ctx, internal) {
196
196
  // production trigger — on by default. --no-vision opts out (e.g. no camera
197
197
  // on this box, or RoboVisionAI_PI isn't installed).
198
198
  const visionEnabled = opts.vision !== false;
199
+ const defaultVideoDevice = process.platform === 'linux' && opts.autoStart
200
+ ? '/dev/robopark-camera'
201
+ : '/dev/video0';
199
202
  const runtimeArgs = [
200
203
  'robot', 'up',
201
204
  '--name', internal.name,
@@ -204,7 +207,7 @@ async function setupRobot(config, opts, ctx, internal) {
204
207
  '--token', internal.token,
205
208
  '--port', String(internal.port),
206
209
  networkFlag,
207
- '--video-device', opts.videoDevice ?? '/dev/video0',
210
+ '--video-device', opts.videoDevice ?? defaultVideoDevice,
208
211
  '--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio (hw:3,0)',
209
212
  ];
210
213
  if (opts.enrollmentToken)
@@ -49,13 +49,34 @@ export async function roboparkVisionAgent(opts) {
49
49
  console.log(` audio: ${chalk.green('RoboVision audio server :8000')}`);
50
50
  console.log();
51
51
  if (opts.foreground) {
52
- if (audioScript) {
53
- const audio = spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } });
54
- audio.unref();
55
- }
56
- const proc = spawn(python, args, { stdio: 'inherit' });
52
+ const audio = audioScript
53
+ ? spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } })
54
+ : null;
55
+ const camera = spawn(python, args, { stdio: 'inherit' });
56
+ let stopping = false;
57
+ const stopChildren = () => {
58
+ if (stopping)
59
+ return;
60
+ stopping = true;
61
+ camera.kill('SIGTERM');
62
+ audio?.kill('SIGTERM');
63
+ };
64
+ process.once('SIGINT', stopChildren);
65
+ process.once('SIGTERM', stopChildren);
66
+ // If either required media service exits, tear down its sibling. The
67
+ // robot runtime then restarts one coherent pair instead of accumulating
68
+ // orphan audio servers and duplicate owners of ports/devices.
57
69
  await new Promise((resolve) => {
58
- proc.on('close', () => resolve());
70
+ let resolved = false;
71
+ const finish = () => {
72
+ if (resolved)
73
+ return;
74
+ resolved = true;
75
+ stopChildren();
76
+ resolve();
77
+ };
78
+ camera.once('close', finish);
79
+ audio?.once('close', finish);
59
80
  });
60
81
  return;
61
82
  }
@@ -127,3 +127,26 @@ $s.events | Where-Object session_id -eq $id | Select-Object timestamp,stage,stat
127
127
  ```
128
128
 
129
129
  Required result: `playback_started ok`.
130
+
131
+ ## Fleet Media Reliability Contract (2.8.93+)
132
+
133
+ - Linux setup installs persistent udev permissions for `video4linux` and
134
+ `sound`, plus `/dev/robopark-camera` for the primary USB capture interface.
135
+ - The systemd runtime receives available media groups and uses control-group
136
+ shutdown so descendants cannot survive an update or restart.
137
+ - `/run/lock/robopark-speaker.lock` and
138
+ `/run/lock/robopark-microphone.lock` serialize production media and tests.
139
+ - The vision launcher owns both camera and audio children. If either exits,
140
+ the runtime restarts one coherent pair instead of leaving an orphan owner.
141
+ - RoboVision opens its shared camera worker at boot. The runtime polls camera
142
+ status and recycles media after three consecutive unhealthy checks.
143
+ - Park media operations display camera/audio permission, camera worker state,
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.92",
3
+ "version": "2.8.94",
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",
@@ -0,0 +1,57 @@
1
+ """Cross-process media ownership for Linux robot appliances."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ class MediaLock:
12
+ def __init__(self, kind: str, timeout: float = 5.0):
13
+ self.kind = kind
14
+ self.timeout = timeout
15
+ self.fd: Optional[int] = None
16
+
17
+ def acquire(self) -> "MediaLock":
18
+ if not sys.platform.startswith("linux"):
19
+ return self
20
+ import fcntl
21
+
22
+ lock_dir = Path("/run/lock") if os.access("/run/lock", os.W_OK) else Path("/tmp")
23
+ path = lock_dir / f"robopark-{self.kind}.lock"
24
+ self.fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o660)
25
+ deadline = time.monotonic() + self.timeout
26
+ while True:
27
+ try:
28
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
29
+ os.ftruncate(self.fd, 0)
30
+ os.write(self.fd, f"{os.getpid()}\n".encode("ascii"))
31
+ return self
32
+ except BlockingIOError:
33
+ if time.monotonic() >= deadline:
34
+ self.release()
35
+ raise TimeoutError(f"{self.kind} is owned by another RoboPark media process")
36
+ time.sleep(0.05)
37
+
38
+ def release(self) -> None:
39
+ if self.fd is None:
40
+ return
41
+ try:
42
+ if sys.platform.startswith("linux"):
43
+ import fcntl
44
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
45
+ finally:
46
+ os.close(self.fd)
47
+ self.fd = None
48
+
49
+ def __enter__(self) -> "MediaLock":
50
+ return self.acquire()
51
+
52
+ def __exit__(self, exc_type, exc, traceback) -> None:
53
+ self.release()
54
+
55
+
56
+ def media_lock(kind: str, timeout: float = 5.0) -> MediaLock:
57
+ return MediaLock(kind, timeout)