infinicode 2.8.64 → 2.8.66
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/agents/backends/registry.js +4 -2
- package/dist/kernel/federation/dashboard-html.d.ts +4 -5
- package/dist/kernel/federation/dashboard-html.js +123 -19
- package/dist/kernel/federation/federation.js +1 -0
- package/dist/kernel/federation/transport-http.d.ts +6 -1
- package/dist/kernel/federation/transport-http.js +75 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +193 -17
- package/packages/robopark/scheduler/preview_agent.py +67 -25
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
* for its cam / mic / speaker / hardware drawer)
|
|
15
15
|
* Sessions & Telemetry — RoboPark scheduler stats + production_mode + controls
|
|
16
16
|
*
|
|
17
|
-
* The Park cam panel
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* That token/track is produced by the robot-side RoboPark plugin.
|
|
17
|
+
* The Park cam panel relays RoboVisionAI_PI's existing MJPEG feed through the
|
|
18
|
+
* authenticated mesh. LiveKit remains the voice transport but is deliberately
|
|
19
|
+
* not used for dashboard video, avoiding camera ownership conflicts on robots.
|
|
21
20
|
*
|
|
22
21
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
23
22
|
* template literal without escaping; the client JS uses string concatenation.
|
|
@@ -318,7 +317,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
318
317
|
.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
|
|
319
318
|
.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
|
|
320
319
|
.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
|
|
321
|
-
.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
|
|
320
|
+
.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
|
|
321
|
+
.pk-pipeline{display:grid;gap:0.32rem;margin-top:0.6rem;}
|
|
322
|
+
.pk-pipe-step{display:grid;grid-template-columns:0.55rem 8.2rem 1fr;gap:0.45rem;align-items:start;padding:0.32rem 0.4rem;border-radius:7px;background:rgba(255,255,255,0.025);font-family:var(--mono);font-size:0.56rem;color:var(--muted);}
|
|
323
|
+
.pk-pipe-step i{width:0.48rem;height:0.48rem;border-radius:50%;margin-top:0.08rem;background:var(--muted);box-shadow:0 0 0 2px rgba(255,255,255,0.04);}
|
|
324
|
+
.pk-pipe-step.running i{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite;}
|
|
325
|
+
.pk-pipe-step.ok i{background:var(--ok);box-shadow:0 0 7px rgba(70,220,150,.45);}
|
|
326
|
+
.pk-pipe-step.failed i,.pk-pipe-step.blocked i{background:var(--bad);box-shadow:0 0 7px rgba(255,90,90,.45);}
|
|
327
|
+
.pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.04em;}
|
|
328
|
+
.pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;}
|
|
329
|
+
.pk-pipeline-summary{padding:0.5rem 0.6rem;border:1px solid var(--glass-border);border-radius:8px;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
|
|
330
|
+
.pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}
|
|
331
|
+
.pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}
|
|
332
|
+
.pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}
|
|
322
333
|
.pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}
|
|
323
334
|
.pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
|
|
324
335
|
.pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
|
|
@@ -3276,7 +3287,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
3276
3287
|
// so the "ended" ring can fade out for ~4s after a session ends,
|
|
3277
3288
|
// and the "live/talking" status can persist briefly across polls.
|
|
3278
3289
|
var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }
|
|
3279
|
-
var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, deviceSelectorsReady=false;
|
|
3290
|
+
var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;
|
|
3280
3291
|
var previewOn=false, previewTimer=null; // on-demand operator camera preview
|
|
3281
3292
|
var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
|
|
3282
3293
|
var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[];
|
|
@@ -5366,7 +5377,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5366
5377
|
if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
|
|
5367
5378
|
if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
|
|
5368
5379
|
if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
|
|
5369
|
-
if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
|
|
5380
|
+
if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
|
|
5381
|
+
if(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }
|
|
5370
5382
|
if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
|
|
5371
5383
|
micAnalyser=null;
|
|
5372
5384
|
}
|
|
@@ -5433,7 +5445,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5433
5445
|
// ── on-demand operator preview: open the robot's cam without a scene-detection session ──
|
|
5434
5446
|
function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
|
|
5435
5447
|
function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
|
|
5436
|
-
function startPreview(n){
|
|
5448
|
+
function startPreview(n){
|
|
5449
|
+
previewOn=true; updateGoLive();
|
|
5450
|
+
if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
|
|
5451
|
+
idleCam('connecting');
|
|
5452
|
+
tryVisionOverlay(n, true);
|
|
5453
|
+
return;
|
|
5437
5454
|
previewOn=true; updateGoLive();
|
|
5438
5455
|
var note=$('pk-golivenote'); if(note) note.textContent='requesting preview…';
|
|
5439
5456
|
if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first
|
|
@@ -5450,7 +5467,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5450
5467
|
})
|
|
5451
5468
|
.catch(function(e){ idleCam('unreachable'); if(note) note.textContent='preview request failed'; if(window.console) console.error('RoboPark preview request failed',e); previewOn=false; updateGoLive(); });
|
|
5452
5469
|
}
|
|
5453
|
-
function stopPreview(n){
|
|
5470
|
+
function stopPreview(n){
|
|
5471
|
+
previewOn=false; updateGoLive();
|
|
5472
|
+
if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
|
|
5473
|
+
if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
|
|
5474
|
+
stopVision();
|
|
5475
|
+
var directNote=$('pk-golivenote'); if(directNote) directNote.textContent='';
|
|
5476
|
+
idleCam('idle');
|
|
5477
|
+
return;
|
|
5454
5478
|
previewOn=false; updateGoLive();
|
|
5455
5479
|
if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
|
|
5456
5480
|
if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
|
|
@@ -5465,6 +5489,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5465
5489
|
// server is opt-in because running it beside preview_agent would open the
|
|
5466
5490
|
// same Windows camera twice and make both feeds unreliable.
|
|
5467
5491
|
function tryVisionOverlay(n, force){
|
|
5492
|
+
var directImg=$('pk-vision-img'); if(!directImg) return;
|
|
5493
|
+
var directRef=n.nodeId||n.id||n.name;
|
|
5494
|
+
if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }
|
|
5495
|
+
var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');
|
|
5496
|
+
visionOn=true;
|
|
5497
|
+
directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; directImg.src=''; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable on robot'; if(directLiveNote) directLiveNote.textContent='camera unavailable'; if(directRes) directRes.textContent='OFFLINE'; };
|
|
5498
|
+
directImg.onload=function(){ directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
|
|
5499
|
+
directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
|
|
5500
|
+
return;
|
|
5468
5501
|
var img=$('pk-vision-img'); if(!img) return;
|
|
5469
5502
|
if(!force && String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){
|
|
5470
5503
|
visionOn=false; img.src=''; img.style.display='none'; return;
|
|
@@ -5909,9 +5942,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5909
5942
|
+'<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"/>'
|
|
5910
5943
|
+'<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>'
|
|
5911
5944
|
+'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
|
|
5912
|
-
+'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
|
|
5913
|
-
+'<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>')
|
|
5914
|
-
+ sect('
|
|
5945
|
+
+'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
|
|
5946
|
+
+'<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>')
|
|
5947
|
+
+ sect('Remote production test','<div id="pk-pipeline-summary" class="pk-pipeline-summary">Checking robot, camera, scheduler and LiveKit...</div>'
|
|
5948
|
+
+ '<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>'
|
|
5949
|
+
+ '<div class="pk-note">Starts the camera, sends the same motion trigger used in production, and follows every reported stage remotely.</div>'
|
|
5950
|
+
+ '<div class="pk-pipeline" id="pk-pipeline"></div>')
|
|
5951
|
+
+ 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>')
|
|
5915
5952
|
+ 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>'
|
|
5916
5953
|
+ '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
|
|
5917
5954
|
+ '<div class="rp-field"><label>Frequency</label><select id="pk-spk-freq"><option value="500">500 Hz (low)</option><option value="1000" selected>1000 Hz (1 kHz reference)</option><option value="2000">2000 Hz (presence)</option><option value="3000">3000 Hz (clarity)</option></select></div>'
|
|
@@ -5937,7 +5974,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5937
5974
|
if(deviceSection && speakerSection) speakerSection.after(deviceSection);
|
|
5938
5975
|
refreshDeviceSelectors(n);
|
|
5939
5976
|
b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
|
|
5940
|
-
var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
|
|
5977
|
+
var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
|
|
5941
5978
|
var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
|
|
5942
5979
|
startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
|
|
5943
5980
|
_bindShellSection(n);
|
|
@@ -5958,7 +5995,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5958
5995
|
restartService(n, btn.dataset.svc, btn);
|
|
5959
5996
|
});
|
|
5960
5997
|
var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
|
|
5961
|
-
|
|
5998
|
+
var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
|
|
5999
|
+
var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });
|
|
6000
|
+
var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });
|
|
6001
|
+
pollPipeline(n);
|
|
6002
|
+
if(pipelineTimer) clearInterval(pipelineTimer);
|
|
6003
|
+
pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);
|
|
5962
6004
|
var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
|
|
5963
6005
|
} else {
|
|
5964
6006
|
b.innerHTML =
|
|
@@ -5966,10 +6008,69 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5966
6008
|
+ sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
|
|
5967
6009
|
+ sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
|
|
5968
6010
|
var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
|
|
5969
|
-
}
|
|
5970
|
-
refreshDrawer();
|
|
5971
|
-
}
|
|
5972
|
-
|
|
6011
|
+
}
|
|
6012
|
+
refreshDrawer();
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
var PIPELINE_LABELS={
|
|
6016
|
+
robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',
|
|
6017
|
+
motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',
|
|
6018
|
+
microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',
|
|
6019
|
+
tts_subscribed:'TTS subscribed',playback_started:'Robot playback',session_ended:'Session ended'
|
|
6020
|
+
};
|
|
6021
|
+
function renderPipeline(data){
|
|
6022
|
+
var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'); if(!host||!summary) return;
|
|
6023
|
+
var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;
|
|
6024
|
+
(data.events||[]).forEach(function(ev){
|
|
6025
|
+
if(ev.session_id && sid && ev.session_id!==sid) return;
|
|
6026
|
+
latest[ev.stage]=ev;
|
|
6027
|
+
});
|
|
6028
|
+
['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){
|
|
6029
|
+
if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};
|
|
6030
|
+
});
|
|
6031
|
+
var running=data.overall==='running';
|
|
6032
|
+
host.innerHTML=(data.stages||Object.keys(PIPELINE_LABELS)).map(function(stage){
|
|
6033
|
+
var ev=latest[stage], status=ev&&ev.status||((running&&stage==='motion_detected')?'running':'pending');
|
|
6034
|
+
var detail=ev&&(ev.message||ev.timestamp)||'waiting';
|
|
6035
|
+
if(ev&&ev.timestamp) detail=detail+' · '+new Date(ev.timestamp+'Z').toLocaleTimeString();
|
|
6036
|
+
return '<div class="pk-pipe-step '+esc(status)+'"><i></i><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="detail">'+esc(detail)+'</span></div>';
|
|
6037
|
+
}).join('');
|
|
6038
|
+
var blockers=data.blockers||[];
|
|
6039
|
+
summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');
|
|
6040
|
+
summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?'TEST RUNNING: watch the live camera and stage timeline':'READY: camera-to-speaker remote test can start');
|
|
6041
|
+
}
|
|
6042
|
+
function pollPipeline(n){
|
|
6043
|
+
fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})
|
|
6044
|
+
.then(function(r){ if(!r.ok) throw new Error('scheduler '+r.status); return r.json(); })
|
|
6045
|
+
.then(renderPipeline)
|
|
6046
|
+
.catch(function(e){ var s=$('pk-pipeline-summary'); if(s){ s.className='pk-pipeline-summary blocked'; s.textContent='SCHEDULER UNREACHABLE: '+e.message; } });
|
|
6047
|
+
}
|
|
6048
|
+
function queuePipelineTest(n){
|
|
6049
|
+
var start=$('pk-pipeline-start'); if(start){ start.disabled=true; start.textContent='Starting...'; }
|
|
6050
|
+
if(!previewOn) startPreview(n);
|
|
6051
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/start',{}).then(function(result){
|
|
6052
|
+
if(!result.queued) throw new Error(result.reason||'trigger was not queued');
|
|
6053
|
+
showOut('full production test queued for '+(n.label||n.name));
|
|
6054
|
+
pollPipeline(n);
|
|
6055
|
+
}).catch(function(e){ showOut('production test: '+e.message); pollPipeline(n); }).finally(function(){ if(start){ start.disabled=false; start.textContent='Start full test'; } });
|
|
6056
|
+
}
|
|
6057
|
+
function startPipelineTest(n){
|
|
6058
|
+
var device=schedulerDevice(n)||{};
|
|
6059
|
+
if(!device.id){ showOut('Robot has no scheduler device identity'); return; }
|
|
6060
|
+
if(!device.production_mode){
|
|
6061
|
+
rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){
|
|
6062
|
+
device.production_mode=true; var pm=$('pk-prodmode'); if(pm) pm.checked=true; queuePipelineTest(n);
|
|
6063
|
+
}).catch(function(e){ showOut('Could not enable production mode: '+e.message); });
|
|
6064
|
+
return;
|
|
6065
|
+
}
|
|
6066
|
+
queuePipelineTest(n);
|
|
6067
|
+
}
|
|
6068
|
+
function stopPipelineTest(n){
|
|
6069
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/stop',{}).then(function(){
|
|
6070
|
+
showOut('conversation stopped for '+(n.label||n.name)); pollPipeline(n); startCam(n);
|
|
6071
|
+
}).catch(function(e){ showOut('stop test: '+e.message); });
|
|
6072
|
+
}
|
|
6073
|
+
function refreshDrawer(){
|
|
5973
6074
|
var n=drawerId?byId[drawerId]:null; if(!n) return;
|
|
5974
6075
|
if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){
|
|
5975
6076
|
var devCheck=schedulerDevice(n);
|
|
@@ -6003,7 +6104,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6003
6104
|
function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }
|
|
6004
6105
|
|
|
6005
6106
|
// ── cam: target a LiveKit track; fall back to a styled preview ──
|
|
6006
|
-
function startCam(n){
|
|
6107
|
+
function startCam(n){
|
|
6108
|
+
idleCam('connecting');
|
|
6109
|
+
tryVisionOverlay(n, true);
|
|
6110
|
+
return;
|
|
6007
6111
|
var note=$('pk-camnote');
|
|
6008
6112
|
camAbort = ('AbortController' in window)?new AbortController():null;
|
|
6009
6113
|
idleCam('connecting');
|
|
@@ -86,6 +86,7 @@ export class Federation {
|
|
|
86
86
|
logger,
|
|
87
87
|
getManifest: () => buildManifest(identity, this.describeWithBackends()),
|
|
88
88
|
getNodes: () => this.nodeStatuses(),
|
|
89
|
+
getPeers: () => this.peers(),
|
|
89
90
|
getStatus: () => this.statusSnapshot(),
|
|
90
91
|
runCommand: (input) => this.deps.kernel.command(input),
|
|
91
92
|
commandGate: options.commandGate,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from '../types.js';
|
|
2
|
-
import type { FederationEnvelope, NodeManifest, StreamFrame } from './types.js';
|
|
2
|
+
import type { FederationEnvelope, NodeManifest, PeerInfo, StreamFrame } from './types.js';
|
|
3
3
|
export type RpcHandler = (env: FederationEnvelope, remote: string) => Promise<FederationEnvelope | void>;
|
|
4
4
|
export interface MeshServerOptions {
|
|
5
5
|
host?: string;
|
|
@@ -9,6 +9,8 @@ export interface MeshServerOptions {
|
|
|
9
9
|
getManifest: () => NodeManifest;
|
|
10
10
|
/** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
|
|
11
11
|
getNodes?: () => unknown;
|
|
12
|
+
/** Connected peers and their reachable mesh URLs. */
|
|
13
|
+
getPeers?: () => PeerInfo[];
|
|
12
14
|
/** Optional: consolidated fleet snapshot (self + nodes + hardware + roles +
|
|
13
15
|
* runs + recent activity) for the dashboard and `robopark fleet` (GET /fed/status). */
|
|
14
16
|
getStatus?: () => unknown;
|
|
@@ -50,6 +52,9 @@ export declare class MeshServer {
|
|
|
50
52
|
private seq;
|
|
51
53
|
constructor(opts: MeshServerOptions);
|
|
52
54
|
get streamCount(): number;
|
|
55
|
+
private streamHttp;
|
|
56
|
+
private streamLocalRoboVision;
|
|
57
|
+
private streamPeerRoboVision;
|
|
53
58
|
start(): Promise<void>;
|
|
54
59
|
/** Proxy a dashboard request to the RoboPark scheduler (product data). */
|
|
55
60
|
private proxyToScheduler;
|
|
@@ -20,6 +20,7 @@ import { promisify } from 'node:util';
|
|
|
20
20
|
import { timingSafeEqual } from 'node:crypto';
|
|
21
21
|
import { readFileSync } from 'node:fs';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
23
|
+
import { once } from 'node:events';
|
|
23
24
|
import { frameToSSE, parseSSE } from './protocol.js';
|
|
24
25
|
const execFileAsync = promisify(execFile);
|
|
25
26
|
/** Read the livekit-client UMD bundle from the installed dependency, once.
|
|
@@ -82,6 +83,68 @@ export class MeshServer {
|
|
|
82
83
|
get streamCount() {
|
|
83
84
|
return this.sse.size;
|
|
84
85
|
}
|
|
86
|
+
async streamHttp(req, res, target, headers = {}) {
|
|
87
|
+
const ctrl = new AbortController();
|
|
88
|
+
req.once('close', () => ctrl.abort());
|
|
89
|
+
try {
|
|
90
|
+
const upstream = await fetch(target, { headers, signal: ctrl.signal });
|
|
91
|
+
if (!upstream.ok || !upstream.body) {
|
|
92
|
+
const detail = await upstream.text().catch(() => '');
|
|
93
|
+
res.writeHead(upstream.status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
94
|
+
res.end(detail || `media upstream returned ${upstream.status}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(200, {
|
|
98
|
+
'content-type': upstream.headers.get('content-type') ?? 'multipart/x-mixed-replace; boundary=frame',
|
|
99
|
+
'cache-control': 'no-store, no-cache, must-revalidate',
|
|
100
|
+
pragma: 'no-cache',
|
|
101
|
+
connection: 'keep-alive',
|
|
102
|
+
});
|
|
103
|
+
const reader = upstream.body.getReader();
|
|
104
|
+
for (;;) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done || res.writableEnded)
|
|
107
|
+
break;
|
|
108
|
+
if (!res.write(Buffer.from(value)))
|
|
109
|
+
await once(res, 'drain');
|
|
110
|
+
}
|
|
111
|
+
if (!res.writableEnded)
|
|
112
|
+
res.end();
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (ctrl.signal.aborted)
|
|
116
|
+
return;
|
|
117
|
+
if (!res.headersSent) {
|
|
118
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
119
|
+
res.end(`robot camera unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
else if (!res.writableEnded) {
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
streamLocalRoboVision(req, res) {
|
|
127
|
+
void this.streamHttp(req, res, 'http://127.0.0.1:5000/video_feed');
|
|
128
|
+
}
|
|
129
|
+
streamPeerRoboVision(req, res, nodeRef) {
|
|
130
|
+
const ref = decodeURIComponent(nodeRef).toLowerCase();
|
|
131
|
+
const self = this.opts.getManifest();
|
|
132
|
+
if (self.nodeId.toLowerCase() === ref || self.displayName.toLowerCase() === ref) {
|
|
133
|
+
this.streamLocalRoboVision(req, res);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const peer = (this.opts.getPeers?.() ?? []).find(candidate => candidate.connected &&
|
|
137
|
+
(candidate.nodeId.toLowerCase() === ref || candidate.displayName.toLowerCase() === ref));
|
|
138
|
+
if (!peer) {
|
|
139
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
140
|
+
res.end(`connected robot mesh node not found: ${nodeRef}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const headers = {};
|
|
144
|
+
if (this.opts.token)
|
|
145
|
+
headers.authorization = `Bearer ${this.opts.token}`;
|
|
146
|
+
void this.streamHttp(req, res, `${peer.url.replace(/\/+$/, '')}/fed/media/video_feed`, headers);
|
|
147
|
+
}
|
|
85
148
|
async start() {
|
|
86
149
|
const handler = (req, res) => this.handle(req, res);
|
|
87
150
|
this.server = this.opts.tls
|
|
@@ -344,6 +407,18 @@ export class MeshServer {
|
|
|
344
407
|
res.end(js);
|
|
345
408
|
return;
|
|
346
409
|
}
|
|
410
|
+
// RoboVision owns the robot camera. Satellites expose the existing local
|
|
411
|
+
// MJPEG feed, while the hub relays it over the authenticated mesh. This
|
|
412
|
+
// avoids opening /dev/video0 again in LiveKit and works across private LANs.
|
|
413
|
+
if (req.method === 'GET' && path === '/fed/media/video_feed') {
|
|
414
|
+
this.streamLocalRoboVision(req, res);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const robotVideo = path.match(/^\/fed\/media\/robots\/([^/]+)\/video_feed$/);
|
|
418
|
+
if (req.method === 'GET' && robotVideo) {
|
|
419
|
+
this.streamPeerRoboVision(req, res, robotVideo[1]);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
347
422
|
if (req.method === 'GET' && path === '/fed/status') {
|
|
348
423
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
349
424
|
res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
|
package/package.json
CHANGED
|
@@ -216,12 +216,20 @@ class DeviceEnrollResponse(BaseModel):
|
|
|
216
216
|
device_token: str
|
|
217
217
|
scheduler_url: str
|
|
218
218
|
|
|
219
|
-
class DeviceHeartbeat(BaseModel):
|
|
219
|
+
class DeviceHeartbeat(BaseModel):
|
|
220
220
|
status: Optional[str] = None
|
|
221
221
|
ip: Optional[str] = None
|
|
222
222
|
uptime_seconds: Optional[int] = None
|
|
223
223
|
production_mode: Optional[bool] = None
|
|
224
|
-
device_inventory: Optional[dict] = None
|
|
224
|
+
device_inventory: Optional[dict] = None
|
|
225
|
+
|
|
226
|
+
class PipelineEventPayload(BaseModel):
|
|
227
|
+
stage: str
|
|
228
|
+
status: str = "ok"
|
|
229
|
+
message: Optional[str] = None
|
|
230
|
+
session_id: Optional[str] = None
|
|
231
|
+
source: str = "robot"
|
|
232
|
+
details: Optional[dict] = None
|
|
225
233
|
|
|
226
234
|
class SupervisorStatusReport(BaseModel):
|
|
227
235
|
services: List[ServiceStatus] = []
|
|
@@ -439,7 +447,7 @@ async def init_db():
|
|
|
439
447
|
);
|
|
440
448
|
CREATE INDEX IF NOT EXISTS idx_shell_requests_device ON shell_requests(device_id, requested_at);
|
|
441
449
|
|
|
442
|
-
CREATE TABLE IF NOT EXISTS history_log (
|
|
450
|
+
CREATE TABLE IF NOT EXISTS history_log (
|
|
443
451
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
444
452
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
445
453
|
category TEXT NOT NULL,
|
|
@@ -447,8 +455,22 @@ async def init_db():
|
|
|
447
455
|
entity_id TEXT,
|
|
448
456
|
action TEXT,
|
|
449
457
|
actor TEXT,
|
|
450
|
-
details TEXT
|
|
451
|
-
);
|
|
458
|
+
details TEXT
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
CREATE TABLE IF NOT EXISTS pipeline_events (
|
|
462
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
463
|
+
robot_id TEXT NOT NULL,
|
|
464
|
+
session_id TEXT,
|
|
465
|
+
stage TEXT NOT NULL,
|
|
466
|
+
status TEXT NOT NULL,
|
|
467
|
+
message TEXT,
|
|
468
|
+
source TEXT NOT NULL DEFAULT 'robot',
|
|
469
|
+
details TEXT,
|
|
470
|
+
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
471
|
+
);
|
|
472
|
+
CREATE INDEX IF NOT EXISTS idx_pipeline_robot_time
|
|
473
|
+
ON pipeline_events(robot_id, timestamp DESC);
|
|
452
474
|
|
|
453
475
|
-- Scheduler-managed voice stacks (STT/LLM/TTS configuration)
|
|
454
476
|
CREATE TABLE IF NOT EXISTS voice_stacks (
|
|
@@ -1342,8 +1364,8 @@ async def session_joined(session_id: str,
|
|
|
1342
1364
|
await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
|
|
1343
1365
|
return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
|
|
1344
1366
|
|
|
1345
|
-
@app.post("/api/sessions/{session_id}/keepalive")
|
|
1346
|
-
async def session_keepalive(session_id: str,
|
|
1367
|
+
@app.post("/api/sessions/{session_id}/keepalive")
|
|
1368
|
+
async def session_keepalive(session_id: str,
|
|
1347
1369
|
authorization: Optional[str] = Header(default=None),
|
|
1348
1370
|
agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
|
|
1349
1371
|
"""Silence-based session extension signal.
|
|
@@ -1381,7 +1403,29 @@ async def session_keepalive(session_id: str,
|
|
|
1381
1403
|
await db.commit()
|
|
1382
1404
|
if cur.rowcount == 0:
|
|
1383
1405
|
raise HTTPException(404, "Session not found or already ended")
|
|
1384
|
-
return {"status": "ok", "last_activity_at": now}
|
|
1406
|
+
return {"status": "ok", "last_activity_at": now}
|
|
1407
|
+
|
|
1408
|
+
@app.post("/api/sessions/{session_id}/pipeline-events")
|
|
1409
|
+
async def voice_pipeline_event(session_id: str, payload: PipelineEventPayload,
|
|
1410
|
+
authorization: Optional[str] = Header(default=None),
|
|
1411
|
+
agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
|
|
1412
|
+
"""ROBOVOICE callback for worker, STT, LLM and TTS stage timing."""
|
|
1413
|
+
device_authorized = await _authorize_fleet(authorization)
|
|
1414
|
+
agent_authorized = bool(
|
|
1415
|
+
ROBOPARK_AGENT_TOKEN and agent_token
|
|
1416
|
+
and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN)
|
|
1417
|
+
)
|
|
1418
|
+
if not device_authorized and not agent_authorized:
|
|
1419
|
+
raise HTTPException(401, "Invalid or missing agent token")
|
|
1420
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1421
|
+
db.row_factory = aiosqlite.Row
|
|
1422
|
+
async with db.execute("SELECT robot_id FROM sessions WHERE id = ?", (session_id,)) as c:
|
|
1423
|
+
session = await c.fetchone()
|
|
1424
|
+
if not session:
|
|
1425
|
+
raise HTTPException(404, "Session not found")
|
|
1426
|
+
payload.session_id = session_id
|
|
1427
|
+
payload.source = "voice_agent"
|
|
1428
|
+
return {"status": "ok", **(await _insert_pipeline_event(session["robot_id"], payload))}
|
|
1385
1429
|
|
|
1386
1430
|
@app.get("/api/sessions/{session_id}/status")
|
|
1387
1431
|
async def get_session_status(session_id: str):
|
|
@@ -2654,8 +2698,8 @@ async def device_trigger_command(device_id: str,
|
|
|
2654
2698
|
await db.commit()
|
|
2655
2699
|
return {"trigger": True, "source": command["source"], "production_mode": True}
|
|
2656
2700
|
|
|
2657
|
-
@app.post("/api/devices/{device_id}/heartbeat")
|
|
2658
|
-
async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
2701
|
+
@app.post("/api/devices/{device_id}/heartbeat")
|
|
2702
|
+
async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
2659
2703
|
authorization: Optional[str] = Header(default=None)):
|
|
2660
2704
|
if not await _authorize_device(device_id, authorization):
|
|
2661
2705
|
raise HTTPException(401, "Invalid device token")
|
|
@@ -2689,8 +2733,133 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
|
2689
2733
|
return {
|
|
2690
2734
|
"status": "ok",
|
|
2691
2735
|
"production_mode": effective_pm,
|
|
2692
|
-
"server_time": datetime.utcnow().isoformat(),
|
|
2693
|
-
}
|
|
2736
|
+
"server_time": datetime.utcnow().isoformat(),
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
PIPELINE_STAGES = (
|
|
2740
|
+
"robot_online", "camera_ready", "microphone_ready", "speaker_ready",
|
|
2741
|
+
"motion_detected", "scheduler_session", "livekit_join", "camera_published",
|
|
2742
|
+
"microphone_published", "voice_worker", "stt_listening", "llm_response",
|
|
2743
|
+
"tts_subscribed", "playback_started", "session_ended",
|
|
2744
|
+
)
|
|
2745
|
+
PIPELINE_STATUSES = {"pending", "running", "ok", "failed", "blocked", "skipped"}
|
|
2746
|
+
|
|
2747
|
+
async def _insert_pipeline_event(device_id: str, payload: PipelineEventPayload) -> dict:
|
|
2748
|
+
if payload.stage not in PIPELINE_STAGES:
|
|
2749
|
+
raise HTTPException(422, f"Unknown pipeline stage: {payload.stage}")
|
|
2750
|
+
if payload.status not in PIPELINE_STATUSES:
|
|
2751
|
+
raise HTTPException(422, f"Unknown pipeline status: {payload.status}")
|
|
2752
|
+
message = (payload.message or "")[:500]
|
|
2753
|
+
details = json.dumps(payload.details or {}, separators=(",", ":"))[:4000]
|
|
2754
|
+
now = datetime.utcnow().isoformat()
|
|
2755
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2756
|
+
cur = await db.execute(
|
|
2757
|
+
"""INSERT INTO pipeline_events
|
|
2758
|
+
(robot_id, session_id, stage, status, message, source, details, timestamp)
|
|
2759
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
2760
|
+
(device_id, payload.session_id, payload.stage, payload.status, message,
|
|
2761
|
+
(payload.source or "robot")[:40], details, now),
|
|
2762
|
+
)
|
|
2763
|
+
await db.commit()
|
|
2764
|
+
await broadcast("pipeline_event", {"robot_id": device_id, "stage": payload.stage, "status": payload.status})
|
|
2765
|
+
return {"id": cur.lastrowid, "timestamp": now}
|
|
2766
|
+
|
|
2767
|
+
@app.post("/api/devices/{device_id}/pipeline-events")
|
|
2768
|
+
async def device_pipeline_event(device_id: str, payload: PipelineEventPayload,
|
|
2769
|
+
authorization: Optional[str] = Header(default=None)):
|
|
2770
|
+
"""Receive real pipeline stage transitions from the enrolled robot."""
|
|
2771
|
+
if not await _authorize_device(device_id, authorization):
|
|
2772
|
+
raise HTTPException(401, "Invalid device token")
|
|
2773
|
+
return {"status": "ok", **(await _insert_pipeline_event(device_id, payload))}
|
|
2774
|
+
|
|
2775
|
+
@app.get("/api/robots/{robot_id}/pipeline-status")
|
|
2776
|
+
async def robot_pipeline_status(robot_id: str, limit: int = 80):
|
|
2777
|
+
"""One operator-facing snapshot for remote camera-to-playback testing."""
|
|
2778
|
+
limit = max(10, min(limit, 200))
|
|
2779
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2780
|
+
db.row_factory = aiosqlite.Row
|
|
2781
|
+
async with db.execute(
|
|
2782
|
+
"SELECT * FROM devices WHERE id = ? OR lower(name) = lower(?) ORDER BY last_heartbeat DESC LIMIT 1",
|
|
2783
|
+
(robot_id, robot_id),
|
|
2784
|
+
) as c:
|
|
2785
|
+
device = await c.fetchone()
|
|
2786
|
+
if not device:
|
|
2787
|
+
raise HTTPException(404, "Robot device not found")
|
|
2788
|
+
device_id = device["id"]
|
|
2789
|
+
telemetry = await _robot_telemetry(db, device_id)
|
|
2790
|
+
async with db.execute(
|
|
2791
|
+
"SELECT * FROM sessions WHERE robot_id = ? ORDER BY started_at DESC LIMIT 1", (device_id,)
|
|
2792
|
+
) as c:
|
|
2793
|
+
latest_session = await c.fetchone()
|
|
2794
|
+
async with db.execute(
|
|
2795
|
+
"SELECT * FROM pipeline_events WHERE robot_id = ? ORDER BY timestamp DESC LIMIT ?",
|
|
2796
|
+
(device_id, limit),
|
|
2797
|
+
) as c:
|
|
2798
|
+
rows = await c.fetchall()
|
|
2799
|
+
|
|
2800
|
+
try:
|
|
2801
|
+
inventory = json.loads(device["device_inventory"] or "{}")
|
|
2802
|
+
except (TypeError, ValueError):
|
|
2803
|
+
inventory = {}
|
|
2804
|
+
cameras = inventory.get("video") or inventory.get("cameras") or []
|
|
2805
|
+
inputs = inventory.get("audio_input") or inventory.get("audio_inputs") or inventory.get("inputs") or []
|
|
2806
|
+
outputs = inventory.get("audio_output") or inventory.get("audio_outputs") or inventory.get("outputs") or []
|
|
2807
|
+
cameras = [d for d in cameras if str(d.get("id", "")).lower() not in ("auto", "none")]
|
|
2808
|
+
inputs = [d for d in inputs if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
2809
|
+
outputs = [d for d in outputs if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
2810
|
+
heartbeat_age = telemetry.get("heartbeat_age_seconds") if telemetry else None
|
|
2811
|
+
prereqs = {
|
|
2812
|
+
"robot_online": heartbeat_age is not None and heartbeat_age <= 30,
|
|
2813
|
+
"camera_ready": bool(cameras),
|
|
2814
|
+
"microphone_ready": bool(inputs),
|
|
2815
|
+
"speaker_ready": bool(outputs),
|
|
2816
|
+
}
|
|
2817
|
+
events = []
|
|
2818
|
+
for row in reversed(rows):
|
|
2819
|
+
item = dict(row)
|
|
2820
|
+
try:
|
|
2821
|
+
item["details"] = json.loads(item.get("details") or "{}")
|
|
2822
|
+
except (TypeError, ValueError):
|
|
2823
|
+
item["details"] = {}
|
|
2824
|
+
events.append(item)
|
|
2825
|
+
blockers = []
|
|
2826
|
+
if not prereqs["robot_online"]:
|
|
2827
|
+
blockers.append("Robot heartbeat is stale or missing")
|
|
2828
|
+
if not bool(device["production_mode"]):
|
|
2829
|
+
blockers.append("Robot production mode is off")
|
|
2830
|
+
if telemetry and telemetry.get("readiness_code") not in ("ready", "in_session"):
|
|
2831
|
+
blockers.append(telemetry.get("readiness_message") or telemetry["readiness_code"])
|
|
2832
|
+
if not prereqs["camera_ready"]:
|
|
2833
|
+
blockers.append("Camera inventory has not been reported")
|
|
2834
|
+
if not prereqs["microphone_ready"]:
|
|
2835
|
+
blockers.append("Microphone inventory has not been reported")
|
|
2836
|
+
if not prereqs["speaker_ready"]:
|
|
2837
|
+
blockers.append("Speaker inventory has not been reported")
|
|
2838
|
+
return {
|
|
2839
|
+
"robot_id": device_id,
|
|
2840
|
+
"robot_name": device["name"],
|
|
2841
|
+
"overall": "blocked" if blockers else ("running" if latest_session and not latest_session["ended_at"] else "ready"),
|
|
2842
|
+
"blockers": list(dict.fromkeys(blockers)),
|
|
2843
|
+
"prerequisites": prereqs,
|
|
2844
|
+
"telemetry": telemetry,
|
|
2845
|
+
"latest_session": dict(latest_session) if latest_session else None,
|
|
2846
|
+
"events": events,
|
|
2847
|
+
"stages": list(PIPELINE_STAGES),
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
@app.post("/api/robots/{robot_id}/pipeline-test/start")
|
|
2851
|
+
async def start_pipeline_test(robot_id: str):
|
|
2852
|
+
snapshot = await robot_pipeline_status(robot_id)
|
|
2853
|
+
hard_blockers = [b for b in snapshot["blockers"] if "inventory" not in b.lower()]
|
|
2854
|
+
if hard_blockers:
|
|
2855
|
+
return {"queued": False, "reason": hard_blockers[0], "blockers": snapshot["blockers"]}
|
|
2856
|
+
result = await simulate_trigger(snapshot["robot_id"])
|
|
2857
|
+
return {**result, "robot_id": snapshot["robot_id"], "blockers": snapshot["blockers"]}
|
|
2858
|
+
|
|
2859
|
+
@app.post("/api/robots/{robot_id}/pipeline-test/stop")
|
|
2860
|
+
async def stop_pipeline_test(robot_id: str):
|
|
2861
|
+
snapshot = await robot_pipeline_status(robot_id)
|
|
2862
|
+
return await simulate_stop(snapshot["robot_id"])
|
|
2694
2863
|
|
|
2695
2864
|
@app.post("/api/devices/{device_id}/supervisor-status")
|
|
2696
2865
|
async def device_supervisor_status(device_id: str, payload: SupervisorStatusReport,
|
|
@@ -4266,11 +4435,18 @@ async def dashboard():
|
|
|
4266
4435
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
4267
4436
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
4268
4437
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client@1/dist/livekit-client.umd.min.js"></script>
|
|
4269
|
-
</head>
|
|
4270
|
-
<body class="bg-gray-900 text-white">
|
|
4271
|
-
<div
|
|
4272
|
-
|
|
4273
|
-
|
|
4438
|
+
</head>
|
|
4439
|
+
<body class="bg-gray-900 text-white">
|
|
4440
|
+
<div class="bg-amber-950 border-b border-amber-600 px-4 py-3 text-sm text-amber-100">
|
|
4441
|
+
<strong>Legacy scheduler view.</strong> The canonical operator UI is the full RoboPark Control Center Park view on the mesh hub at port 47913.
|
|
4442
|
+
<a id="fullControlCenterLink" class="underline ml-2" href="#">Open full Park Control Center</a>
|
|
4443
|
+
</div>
|
|
4444
|
+
<div id="app"></div>
|
|
4445
|
+
<script>
|
|
4446
|
+
const fullControlCenterLink = document.getElementById('fullControlCenterLink');
|
|
4447
|
+
const meshHost = window.location.hostname || 'localhost';
|
|
4448
|
+
fullControlCenterLink.href = `${window.location.protocol}//${meshHost}:47913/${window.location.search}#park`;
|
|
4449
|
+
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
|
|
4274
4450
|
|
|
4275
4451
|
createApp({
|
|
4276
4452
|
setup() {
|