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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +106 -34
- package/dist/robopark/robot-runtime.js +10 -2
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +18 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/preview_agent.py +33 -13
- package/packages/robopark/vision/app_pi_clean.py +25 -6
|
@@ -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
|
|
6165
|
-
function goLive(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
|
-
|
|
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
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
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)+'"
|
|
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">
|
|
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:
|
|
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',
|
|
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
|
@@ -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",
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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"])
|