infinicode 2.8.103 → 2.8.106
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 +69 -36
- package/dist/robopark/add-robot.js +12 -5
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +25 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +33 -2
- package/packages/robopark/scheduler/preview_agent.py +18 -12
- package/packages/robopark/scheduler/robot_supervisor.py +59 -1
|
@@ -26,7 +26,7 @@ export const DASHBOARD_HTML = `<!doctype html>
|
|
|
26
26
|
<head>
|
|
27
27
|
<meta charset="utf-8">
|
|
28
28
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
-
<meta name="robopark-ui-build" content="provisioning-
|
|
29
|
+
<meta name="robopark-ui-build" content="provisioning-command-20260717-2">
|
|
30
30
|
<title>RoboPark Control</title>
|
|
31
31
|
<style>
|
|
32
32
|
/* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
|
|
@@ -1132,7 +1132,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1132
1132
|
<div class="rp-quickrow">
|
|
1133
1133
|
<div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
|
|
1134
1134
|
<div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
|
|
1135
|
-
<div class="rp-field"><label>Robot network</label><select id="rb-network"><option value="
|
|
1135
|
+
<div class="rp-field"><label>Robot network</label><select id="rb-network"><option value="lan">LAN (onsite default)</option><option value="tailscale">Tailscale</option></select></div>
|
|
1136
1136
|
<div class="rp-field"><label>Mesh token</label><input id="rb-token" spellcheck="false" autocomplete="off" placeholder="required in generated command"></div>
|
|
1137
1137
|
<button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
|
|
1138
1138
|
</div>
|
|
@@ -1175,7 +1175,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1175
1175
|
<div class="rp-config-hero-actions"><button class="rp-btn" id="rp-command-reset">Reset environment</button><button class="rp-btn primary" id="rp-command-copy-all">Copy visible commands</button></div>
|
|
1176
1176
|
</div>
|
|
1177
1177
|
<div class="rp-command-env">
|
|
1178
|
-
<div class="rp-field"><label>Hub LAN IP</label><input id="rp-command-lan" value="192.168.
|
|
1178
|
+
<div class="rp-field"><label>Hub LAN IP</label><input id="rp-command-lan" value="192.168.0.9" spellcheck="false"></div>
|
|
1179
1179
|
<div class="rp-field"><label>Hub Tailscale IP / DNS</label><input id="rp-command-tail" value="100.84.147.24" spellcheck="false"></div>
|
|
1180
1180
|
<div class="rp-field"><label>Site</label><input id="rp-command-site" value="Tel Aviv"></div>
|
|
1181
1181
|
<div class="rp-field"><label>Robot name</label><input id="rp-command-robot" value="bmw" spellcheck="false"></div>
|
|
@@ -1264,8 +1264,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1264
1264
|
var lastFedStatusAt = 0; // updated by poll() on every successful /fed/status fetch
|
|
1265
1265
|
var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
|
|
1266
1266
|
var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
|
|
1267
|
-
var schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory
|
|
1268
|
-
var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)
|
|
1267
|
+
var schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory
|
|
1268
|
+
var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)
|
|
1269
|
+
function schedulerDeviceScore(device){
|
|
1270
|
+
if(!device) return -1;
|
|
1271
|
+
var status=String(device.status||'').trim().toLowerCase();
|
|
1272
|
+
var heartbeat=Date.parse(device.last_heartbeat||'');
|
|
1273
|
+
var enrolled=Date.parse(device.enrolled_at||device.created_at||'');
|
|
1274
|
+
var fresh=Number.isFinite(heartbeat)&&(Date.now()-heartbeat)<45000;
|
|
1275
|
+
var score=fresh?1000000000000000:0;
|
|
1276
|
+
if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=1000000000000;
|
|
1277
|
+
if(Number.isFinite(heartbeat)) score+=heartbeat;
|
|
1278
|
+
else if(Number.isFinite(enrolled)) score+=enrolled;
|
|
1279
|
+
return score;
|
|
1280
|
+
}
|
|
1269
1281
|
var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)
|
|
1270
1282
|
var FALLBACK_SLOTS = [
|
|
1271
1283
|
{label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
|
|
@@ -1732,17 +1744,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1732
1744
|
// A stale re-enrollment can leave more than one row with the same
|
|
1733
1745
|
// display name. Keep the live/fresh device, never whichever SQLite
|
|
1734
1746
|
// happened to return last, because all operator routes are id-keyed.
|
|
1735
|
-
function deviceScore(device){
|
|
1736
|
-
var status=norm(device&&device.status), at=Date.parse(device&&device.last_heartbeat||''), score=0;
|
|
1737
|
-
if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=10000;
|
|
1738
|
-
if(Number.isFinite(at)) score+=Math.max(0, Math.floor(at/1000));
|
|
1739
|
-
return score;
|
|
1740
|
-
}
|
|
1741
1747
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1742
1748
|
deviceById[device.id] = device;
|
|
1743
1749
|
if(device.name){
|
|
1744
1750
|
var key=norm(device.name), current=deviceByName[key];
|
|
1745
|
-
if(!current ||
|
|
1751
|
+
if(!current || schedulerDeviceScore(device)>schedulerDeviceScore(current)) deviceByName[key] = device;
|
|
1746
1752
|
}
|
|
1747
1753
|
});
|
|
1748
1754
|
schedulerDevicesById = deviceById;
|
|
@@ -2262,10 +2268,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2262
2268
|
// needed to see it; 'robopark add-robot' mints its own token when run ──
|
|
2263
2269
|
function buildAddRobotCommand(name, characterId){
|
|
2264
2270
|
if(!name) return '';
|
|
2265
|
-
var
|
|
2271
|
+
var network=$('rb-network')&&$('rb-network').value||'lan';
|
|
2272
|
+
var commandEnv=rpCommandEnv();
|
|
2273
|
+
var hubHost=network==='tailscale'?commandEnv.tail:commandEnv.lan;
|
|
2274
|
+
var hubUrl=/^https?:\/\//i.test(hubHost)?hubHost.replace(/\\/+$/,''):'http://'+hubHost.replace(/\\/+$/,'')+':47913';
|
|
2266
2275
|
var schedulerUrl=hubUrl+'/robopark';
|
|
2267
|
-
var requestedNetwork=$('rb-network')&&$('rb-network').value||'auto';
|
|
2268
|
-
var network=requestedNetwork==='auto'?(/^100\\./.test(location.hostname)||/\\.ts\\.net$/i.test(location.hostname)?'tailscale':'lan'):requestedNetwork;
|
|
2269
2276
|
var argv=['robopark','add-robot',name];
|
|
2270
2277
|
if(characterId) argv.push('--character', characterId);
|
|
2271
2278
|
var site=$('rb-site')&&$('rb-site').value.trim();if(site)argv.push('--site',site);
|
|
@@ -2274,8 +2281,22 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2274
2281
|
argv.push('--token', meshToken);
|
|
2275
2282
|
if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);
|
|
2276
2283
|
argv.push(network==='tailscale'?'--tailscale':'--lan','--start','--auto-start','--yes');
|
|
2277
|
-
return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '"'+a.replace(/"/g,'\\"')+'"' : a; }).join(' ');
|
|
2278
|
-
}
|
|
2284
|
+
return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '"'+a.replace(/"/g,'\\"')+'"' : a; }).join(' ');
|
|
2285
|
+
}
|
|
2286
|
+
function buildPadRobotCommand(name,characterId,enrollmentToken){
|
|
2287
|
+
var env=rpCommandEnv(),hubHost=env.lan||'192.168.0.9';
|
|
2288
|
+
var hubUrl=/^https?:\/\//i.test(hubHost)?hubHost.replace(/\\\/+$/,''):'http://'+hubHost.replace(/\\\/+$/,'')+':47913';
|
|
2289
|
+
var meshToken=env.token||dashboardMeshToken()||'<mesh-token>',site=env.site||'Tel Aviv';
|
|
2290
|
+
var argv;
|
|
2291
|
+
if(enrollmentToken){
|
|
2292
|
+
argv=['sudo','robopark','setup','--robot','--name',name,'--site',site,'--hub-url',hubUrl,'--token',meshToken,'--scheduler-port','8080','--port','47913','--lan','--enrollment-token',enrollmentToken,'--start','--auto-start','--yes'];
|
|
2293
|
+
}else{
|
|
2294
|
+
argv=['robopark','add-robot',name];
|
|
2295
|
+
if(characterId)argv.push('--character',characterId);
|
|
2296
|
+
argv.push('--site',site,'--hub-url',hubUrl,'--token',meshToken,'--scheduler-url',hubUrl+'/robopark','--lan','--start','--auto-start','--yes');
|
|
2297
|
+
}
|
|
2298
|
+
return argv.map(function(a){return /[^a-zA-Z0-9_./:=,-]/.test(a)?'"'+String(a).replace(/"/g,'\\"')+'"':String(a);}).join(' ');
|
|
2299
|
+
}
|
|
2279
2300
|
function refreshRobotCliCommand(){
|
|
2280
2301
|
var name=$('rb-name').value.trim(); var cid=$('rb-char').value||'';
|
|
2281
2302
|
var row=$('rb-clicmd-row'), inp=$('rb-clicmd');
|
|
@@ -2672,7 +2693,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2672
2693
|
var RP_COMMAND_ENV_KEY='rp-command-env-v1';
|
|
2673
2694
|
var RP_CUSTOM_COMMANDS_KEY='rp-custom-commands-v1';
|
|
2674
2695
|
var rpVisibleCommands=[];
|
|
2675
|
-
function rpCommandDefaults(){return {lan:'192.168.
|
|
2696
|
+
function rpCommandDefaults(){return {lan:'192.168.0.9',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:dashboardMeshToken()};}
|
|
2676
2697
|
function rpLoadStoredJson(key,fallback){try{var value=JSON.parse(localStorage.getItem(key)||'null');return value&&typeof value==='object'?value:fallback;}catch(e){return fallback;}}
|
|
2677
2698
|
function rpCommandEnv(){
|
|
2678
2699
|
var d=rpCommandDefaults(),s=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});
|
|
@@ -6880,11 +6901,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6880
6901
|
livekit_url:$('pk-setup-lk').value.trim()||null,
|
|
6881
6902
|
video_device:'auto', audio_device:'default'
|
|
6882
6903
|
};
|
|
6883
|
-
rpAction('POST','/robopark/api/devices',body).then(function(j){
|
|
6884
|
-
var token=(j&&j.enrollment_token)||'';
|
|
6885
|
-
$('pk-setup-token').value=token;
|
|
6886
|
-
$('pk-setup-result').style.display='block';
|
|
6887
|
-
|
|
6904
|
+
rpAction('POST','/robopark/api/devices',body).then(function(j){
|
|
6905
|
+
var token=(j&&j.enrollment_token)||'';
|
|
6906
|
+
$('pk-setup-token').value=token;
|
|
6907
|
+
$('pk-setup-result').style.display='block';
|
|
6908
|
+
var command=$('pk-setup-clicmd'),label=$('pk-setup-clicmd-label');
|
|
6909
|
+
if(command)command.value=buildPadRobotCommand(name,body.character_id||n.id,token);
|
|
6910
|
+
if(label)label.textContent='run this ON THE ROBOT (uses the token minted above)';
|
|
6911
|
+
showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));
|
|
6888
6912
|
pollRoboparkQuiet();
|
|
6889
6913
|
}).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });
|
|
6890
6914
|
}
|
|
@@ -7016,14 +7040,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7016
7040
|
function schedulerDevice(n){
|
|
7017
7041
|
// Deliberately does not call schedRow: telemetry can contain a legacy
|
|
7018
7042
|
// robot name, while this lookup must choose the current enrolled device.
|
|
7043
|
+
var candidates=[];
|
|
7019
7044
|
var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];
|
|
7020
|
-
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]])
|
|
7045
|
+
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) candidates.push(schedulerDevicesById[ids[i]]);
|
|
7021
7046
|
var names=[n.name,n.displayName,n.label];
|
|
7022
7047
|
for(var j=0;j<names.length;j++){
|
|
7023
7048
|
var key=String(names[j]||'').toLowerCase();
|
|
7024
|
-
if(key&&schedulerDevicesByName[key])
|
|
7049
|
+
if(key&&schedulerDevicesByName[key]) candidates.push(schedulerDevicesByName[key]);
|
|
7025
7050
|
}
|
|
7026
|
-
return
|
|
7051
|
+
candidates=candidates.filter(function(device,index,list){return device&&list.indexOf(device)===index;});
|
|
7052
|
+
candidates.sort(function(a,b){return schedulerDeviceScore(b)-schedulerDeviceScore(a);});
|
|
7053
|
+
return candidates[0]||null;
|
|
7027
7054
|
}
|
|
7028
7055
|
function deviceList(inv, key, fallback){
|
|
7029
7056
|
var list=inv&&Array.isArray(inv[key])?inv[key]:[];
|
|
@@ -7422,7 +7449,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7422
7449
|
+ '<div class="rp-field"><label>Robot name</label><input id="pk-setup-name" value="'+esc(defaultName)+'"></div>'
|
|
7423
7450
|
+ '<div class="rp-field"><label>Character preset</label><select id="pk-setup-char"><option value="">— auto —</option>'+presetOptions+'</select></div>'
|
|
7424
7451
|
+ '<div class="rp-quickrow" style="padding:0;"><button class="rp-btn primary" id="pk-setup-btn" data-preset="'+esc(n.id)+'" style="width:fit-content;">Mint enrollment token & add robot</button></div>'
|
|
7425
|
-
+ '<div class="rp-field"><label>or run this ON THE ROBOT (mints its own token)</label><
|
|
7452
|
+
+ '<div class="rp-field"><label id="pk-setup-clicmd-label">or run this ON THE ROBOT (mints its own token)</label><textarea id="pk-setup-clicmd" readonly rows="4" style="resize:vertical;font-family:var(--mono);font-size:.62rem;"></textarea></div>'
|
|
7426
7453
|
+ '<div class="rp-quickrow" style="padding:0;"><button class="rp-btn" id="pk-setup-clicmd-copy" style="width:fit-content;">📋 Copy command</button></div>'
|
|
7427
7454
|
+ '<details class="rp-advanced"><summary>Advanced — pre-set network/voice config</summary>'
|
|
7428
7455
|
+ '<div class="rp-advanced-grid">'
|
|
@@ -7436,10 +7463,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7436
7463
|
+ '<div class="pk-sect" id="pk-setup-result" style="display:none;"><h4>Enrollment token (copy once)</h4><input id="pk-setup-token" readonly style="margin-bottom:0.4rem;"><button class="rp-btn" id="pk-setup-copy">Copy</button></div>');
|
|
7437
7464
|
var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });
|
|
7438
7465
|
var copyBtn=$('pk-setup-copy'); if(copyBtn) copyBtn.addEventListener('click', function(){ var t=$('pk-setup-token'); if(t){ t.select(); try{document.execCommand('copy');}catch(e){} showOut('token copied'); } });
|
|
7439
|
-
function refreshPadCliCommand(){
|
|
7440
|
-
var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;
|
|
7441
|
-
var el=$('pk-setup-clicmd');
|
|
7442
|
-
}
|
|
7466
|
+
function refreshPadCliCommand(){
|
|
7467
|
+
var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;
|
|
7468
|
+
var el=$('pk-setup-clicmd'),label=$('pk-setup-clicmd-label');if(el)el.value=buildPadRobotCommand(nm,cid,'');if(label)label.textContent='or run this ON THE ROBOT (mints its own token)';
|
|
7469
|
+
}
|
|
7443
7470
|
var padNameEl=$('pk-setup-name'); if(padNameEl) padNameEl.addEventListener('input', refreshPadCliCommand);
|
|
7444
7471
|
var padCharEl=$('pk-setup-char'); if(padCharEl) padCharEl.addEventListener('change', refreshPadCliCommand);
|
|
7445
7472
|
var padCliCopy=$('pk-setup-clicmd-copy'); if(padCliCopy) padCliCopy.addEventListener('click', function(){ var i=$('pk-setup-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied — run it on the robot itself'); });
|
|
@@ -7611,7 +7638,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7611
7638
|
function motorStatus(message,kind){var host=$('pk-motor-status');if(host){host.textContent=message;host.className='pk-motor-status '+(kind||'');}}
|
|
7612
7639
|
function loadMotorProfile(n){
|
|
7613
7640
|
var robotId=schedId(n);motorStatus('Loading saved motor profile...');
|
|
7614
|
-
getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});
|
|
7641
|
+
getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||data.motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();if(motorProfile.registry.length){motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');return;}motorStatus('No central registry; discovering robot-local relays...');return discoverMotorRelays(n,true);}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});
|
|
7615
7642
|
}
|
|
7616
7643
|
function saveMotorProfile(n){
|
|
7617
7644
|
captureMotorEditor();var robotId=schedId(n),url=motorProfile.motor_server_url;
|
|
@@ -7628,11 +7655,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7628
7655
|
captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');
|
|
7629
7656
|
saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});
|
|
7630
7657
|
}
|
|
7658
|
+
function discoverMotorRelays(n,automatic){
|
|
7659
|
+
var robotId=schedId(n);motorStatus((automatic?'Automatically discovering':'Discovering')+' relays from the robot motor server...');
|
|
7660
|
+
return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/discover',{}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-discover');}).then(function(result){
|
|
7661
|
+
if(!result.ok)throw new Error(result.error||'robot motor discovery failed');
|
|
7662
|
+
if(!result.registry||!result.registry.length)throw new Error('Robot motor server has no registered relays. Register the wired BCM pins once; unknown pins will not be pulsed.');
|
|
7663
|
+
motorProfile.registry=result.registry;renderMotorEditor();motorStatus('Discovered '+result.registry.length+' robot-local relay(s); saving centrally...','ok');return saveMotorProfile(n);
|
|
7664
|
+
});
|
|
7665
|
+
}
|
|
7631
7666
|
function testAllMotorRelays(n){
|
|
7632
|
-
captureMotorEditor();
|
|
7633
|
-
if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return;
|
|
7634
|
-
var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');
|
|
7635
|
-
saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});
|
|
7667
|
+
captureMotorEditor();var ready=motorProfile.registry.length?Promise.resolve():discoverMotorRelays(n,true);
|
|
7668
|
+
ready.then(function(){if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return null;var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');return saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');});}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});
|
|
7636
7669
|
}
|
|
7637
7670
|
|
|
7638
7671
|
var pipelineHistoryPage=1;
|
|
@@ -50,11 +50,18 @@ function schedulerApiUrl(schedulerUrl, path, token) {
|
|
|
50
50
|
}
|
|
51
51
|
async function mintEnrollmentToken(schedulerUrl, name, characterId, token) {
|
|
52
52
|
const url = schedulerApiUrl(schedulerUrl, '/api/devices', token);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ name, character_id: characterId }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const cause = error.cause;
|
|
63
|
+
throw new Error(`cannot reach scheduler through ${schedulerUrl}: ${cause?.message ?? error.message}`);
|
|
64
|
+
}
|
|
58
65
|
if (!res.ok) {
|
|
59
66
|
const text = await res.text().catch(() => '');
|
|
60
67
|
throw new Error(`${res.status}: ${text || res.statusText}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RoboPark Production Memory
|
|
2
2
|
|
|
3
|
-
Last verified: 2026-07-
|
|
3
|
+
Last verified: 2026-07-17
|
|
4
4
|
|
|
5
5
|
This records production facts future operators and coding agents must
|
|
6
6
|
preserve. Never add enrollment tokens, mesh tokens, API keys, or device tokens.
|
|
@@ -16,6 +16,30 @@ preserve. Never add enrollment tokens, mesh tokens, API keys, or device tokens.
|
|
|
16
16
|
- Robots publish camera and microphone tracks to LiveKit. Park camera preview
|
|
17
17
|
uses the RoboVision relay rather than LiveKit video.
|
|
18
18
|
|
|
19
|
+
## Production Robot Network Boundary
|
|
20
|
+
|
|
21
|
+
- All onsite production robots, including RoboPanda and BMW/VIXEN, are LAN
|
|
22
|
+
satellites of `livekit-1`. They are not expected to be directly reachable
|
|
23
|
+
from the development laptop, even when the laptop can reach `livekit-1`
|
|
24
|
+
through Tailscale.
|
|
25
|
+
- RoboPanda is reachable only through the `livekit-1` LAN mesh. A missing Panda
|
|
26
|
+
node in the development laptop's direct peer list does not by itself prove
|
|
27
|
+
Panda is offline; inspect it from `livekit-1` and through the scheduler.
|
|
28
|
+
- Route robot commands, spawned agents, supervisor shell requests, camera
|
|
29
|
+
relays, media tests, and production diagnostics through `livekit-1`. Do not
|
|
30
|
+
substitute direct SSH or a development-laptop LAN route.
|
|
31
|
+
- The control path is: operator -> Tailscale/mesh -> `livekit-1:47913` ->
|
|
32
|
+
scheduler proxy `/robopark` -> robot heartbeat/supervisor on the onsite LAN.
|
|
33
|
+
The camera path is: browser -> authenticated `livekit-1` RoboVision mesh
|
|
34
|
+
relay -> robot-local RoboVision service.
|
|
35
|
+
- `livekit-1` currently advertises LAN IPv4 `192.168.0.9`; verify it on the hub
|
|
36
|
+
before generating commands because DHCP may change it. Never use the
|
|
37
|
+
development laptop's LAN address as a robot hub address.
|
|
38
|
+
- When a robot reports a stale heartbeat, diagnose from `livekit-1` first:
|
|
39
|
+
scheduler enrollment row, hub-side mesh membership, robot heartbeat, then
|
|
40
|
+
robot-local runtime logs. Camera troubleshooting starts only after heartbeat
|
|
41
|
+
and mesh connectivity are healthy.
|
|
42
|
+
|
|
19
43
|
## BMW Launch Device Lock
|
|
20
44
|
|
|
21
45
|
- Camera: `/dev/video0` (`H65 USB CAMERA`).
|
package/package.json
CHANGED
|
@@ -2671,7 +2671,15 @@ async def list_devices():
|
|
|
2671
2671
|
"""List all enrolled Pi devices."""
|
|
2672
2672
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
2673
2673
|
db.row_factory = aiosqlite.Row
|
|
2674
|
-
async with db.execute(
|
|
2674
|
+
async with db.execute(
|
|
2675
|
+
"""SELECT * FROM devices
|
|
2676
|
+
ORDER BY lower(name),
|
|
2677
|
+
CASE lower(status)
|
|
2678
|
+
WHEN 'online' THEN 0 WHEN 'running' THEN 1
|
|
2679
|
+
WHEN 'detecting' THEN 2 WHEN 'connecting' THEN 3
|
|
2680
|
+
ELSE 4 END,
|
|
2681
|
+
last_heartbeat DESC, enrolled_at DESC, created_at DESC"""
|
|
2682
|
+
) as cursor:
|
|
2675
2683
|
return [_device_row_to_model(r) for r in await cursor.fetchall()]
|
|
2676
2684
|
|
|
2677
2685
|
@app.get("/api/devices/by-room/{room_name}")
|
|
@@ -3984,7 +3992,14 @@ async def _resolve_device_by_id_or_name(robot_id: str):
|
|
|
3984
3992
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
3985
3993
|
db.row_factory = aiosqlite.Row
|
|
3986
3994
|
async with db.execute(
|
|
3987
|
-
"SELECT * FROM devices WHERE id=? OR lower(name)=lower(?)
|
|
3995
|
+
"""SELECT * FROM devices WHERE id=? OR lower(name)=lower(?)
|
|
3996
|
+
ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END,
|
|
3997
|
+
CASE lower(status)
|
|
3998
|
+
WHEN 'online' THEN 0 WHEN 'running' THEN 1
|
|
3999
|
+
WHEN 'detecting' THEN 2 WHEN 'connecting' THEN 3
|
|
4000
|
+
ELSE 4 END,
|
|
4001
|
+
last_heartbeat DESC, enrolled_at DESC, created_at DESC
|
|
4002
|
+
LIMIT 1""",
|
|
3988
4003
|
(robot_id, robot_id, robot_id),
|
|
3989
4004
|
) as cursor:
|
|
3990
4005
|
device = await cursor.fetchone()
|
|
@@ -4131,6 +4146,22 @@ async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300)
|
|
|
4131
4146
|
})
|
|
4132
4147
|
return {"queued": True, "request_id": request_id, "device_id": model.id}
|
|
4133
4148
|
|
|
4149
|
+
@app.post("/api/robots/{robot_id}/motors/discover")
|
|
4150
|
+
async def discover_robot_motors(robot_id: str):
|
|
4151
|
+
"""Import relays already registered by the robot-local motor server.
|
|
4152
|
+
|
|
4153
|
+
This is read-only on the robot. It never scans or pulses unregistered GPIO.
|
|
4154
|
+
The dashboard validates and persists the returned registry before testing.
|
|
4155
|
+
"""
|
|
4156
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
4157
|
+
model = _device_row_to_model(device)
|
|
4158
|
+
if not model.motor_server_url:
|
|
4159
|
+
raise HTTPException(409, "Robot has no motor_server_url")
|
|
4160
|
+
request_id = await _queue_shell_request_async(model.id, "motor_discover", "motor_server", {
|
|
4161
|
+
"motor_server_url": model.motor_server_url,
|
|
4162
|
+
})
|
|
4163
|
+
return {"queued": True, "request_id": request_id, "device_id": model.id}
|
|
4164
|
+
|
|
4134
4165
|
@app.post("/api/robots/{robot_id}/motors/test-all")
|
|
4135
4166
|
async def test_all_robot_motors(robot_id: str, duration_ms: int = 300, pause_ms: int = 200):
|
|
4136
4167
|
device = await _resolve_device_by_id_or_name(robot_id)
|
|
@@ -426,8 +426,8 @@ async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str
|
|
|
426
426
|
return device_id, device_token
|
|
427
427
|
|
|
428
428
|
|
|
429
|
-
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
|
|
430
|
-
"""Enroll this Pi
|
|
429
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
|
|
430
|
+
"""Enroll this Pi and return the exact scheduler identity and token."""
|
|
431
431
|
import socket
|
|
432
432
|
payload = {
|
|
433
433
|
"enrollment_token": enrollment_token,
|
|
@@ -443,16 +443,17 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
|
|
|
443
443
|
)
|
|
444
444
|
r.raise_for_status()
|
|
445
445
|
data = r.json()
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
446
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
447
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
448
|
+
if not device_id or not device_token:
|
|
449
|
+
raise RuntimeError("enrollment did not return device credentials")
|
|
449
450
|
_save_token(device_token)
|
|
450
451
|
cfg = _load_config()
|
|
451
|
-
cfg["device_id"] =
|
|
452
|
+
cfg["device_id"] = device_id
|
|
452
453
|
cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
|
|
453
454
|
_save_config(cfg)
|
|
454
|
-
logger.info(
|
|
455
|
-
return device_token
|
|
455
|
+
logger.info("enrolled as device %s", device_id)
|
|
456
|
+
return device_id, device_token
|
|
456
457
|
|
|
457
458
|
|
|
458
459
|
def _get_lan_ip() -> Optional[str]:
|
|
@@ -629,11 +630,16 @@ class PreviewAgent:
|
|
|
629
630
|
|
|
630
631
|
async def run(self) -> None:
|
|
631
632
|
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
633
|
+
# A UI-minted token identifies a specific pre-created device row.
|
|
634
|
+
# Consume it before generic mesh recovery so heartbeats cannot bind to
|
|
635
|
+
# a stale same-name identity.
|
|
635
636
|
if not self.device_token and enrollment_token:
|
|
636
|
-
self.device_token = await _enroll(
|
|
637
|
+
self.device_id, self.device_token = await _enroll(
|
|
638
|
+
self.scheduler_url, enrollment_token, self.robot_id
|
|
639
|
+
)
|
|
640
|
+
self._mesh_bootstrap_ready = True
|
|
641
|
+
elif _mesh_proxy_headers():
|
|
642
|
+
await self._ensure_mesh_identity()
|
|
637
643
|
|
|
638
644
|
if not self.device_token:
|
|
639
645
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
@@ -427,6 +427,8 @@ def _handle_shell_command(identity, cmd: dict) -> None:
|
|
|
427
427
|
result = _run_shell_command(params.get("name", ""), params)
|
|
428
428
|
elif kind == "speaker_test":
|
|
429
429
|
result = _speaker_roundtrip_test(params)
|
|
430
|
+
elif kind == "motor_discover":
|
|
431
|
+
result = _discover_motor_registry(params)
|
|
430
432
|
elif kind == "motor_sequence":
|
|
431
433
|
result = _run_motor_sequence(params)
|
|
432
434
|
else:
|
|
@@ -434,6 +436,45 @@ def _handle_shell_command(identity, cmd: dict) -> None:
|
|
|
434
436
|
_post_supervisor_output(identity, kind, service, result, request_id)
|
|
435
437
|
|
|
436
438
|
|
|
439
|
+
def _discover_motor_registry(params: dict) -> dict:
|
|
440
|
+
"""Read the robot-local motor registry without touching any GPIO output."""
|
|
441
|
+
import httpx
|
|
442
|
+
import re
|
|
443
|
+
|
|
444
|
+
base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
|
|
445
|
+
if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
|
|
446
|
+
return {"ok": False, "error": "motor server must be robot-local"}
|
|
447
|
+
try:
|
|
448
|
+
with httpx.Client(timeout=5.0) as client:
|
|
449
|
+
response = client.get(f"{base}/list-motors")
|
|
450
|
+
response.raise_for_status()
|
|
451
|
+
raw_motors = response.json().get("motors", [])
|
|
452
|
+
registry, used_ids, used_gpios = [], set(), set()
|
|
453
|
+
for index, raw in enumerate(raw_motors[:32]):
|
|
454
|
+
gpio = int(raw.get("gpio", -1))
|
|
455
|
+
if gpio < 2 or gpio > 27 or gpio in used_gpios:
|
|
456
|
+
continue
|
|
457
|
+
name = str(raw.get("name") or f"Relay {index + 1}").strip()[:60]
|
|
458
|
+
base_id = re.sub(r"[^a-z0-9_-]+", "-", name.lower()).strip("-") or f"relay-{index + 1}"
|
|
459
|
+
motor_id, suffix = base_id[:32], 2
|
|
460
|
+
while motor_id in used_ids:
|
|
461
|
+
tail = f"-{suffix}"
|
|
462
|
+
motor_id = f"{base_id[:32-len(tail)]}{tail}"
|
|
463
|
+
suffix += 1
|
|
464
|
+
used_ids.add(motor_id)
|
|
465
|
+
used_gpios.add(gpio)
|
|
466
|
+
registry.append({
|
|
467
|
+
"id": motor_id,
|
|
468
|
+
"name": name,
|
|
469
|
+
"gpio": gpio,
|
|
470
|
+
"active_high": bool(raw.get("active_high", True)),
|
|
471
|
+
"max_duration_ms": 3000,
|
|
472
|
+
})
|
|
473
|
+
return {"ok": True, "registry": registry, "count": len(registry), "motor_server_url": base}
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
return {"ok": False, "error": f"motor registry discovery failed: {type(exc).__name__}: {exc}"}
|
|
476
|
+
|
|
477
|
+
|
|
437
478
|
def _run_motor_sequence(params: dict) -> dict:
|
|
438
479
|
"""Run one validated scheduler sequence against the robot-local motor API."""
|
|
439
480
|
import httpx
|
|
@@ -444,6 +485,7 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
444
485
|
steps = params.get("steps") or []
|
|
445
486
|
started = time.monotonic()
|
|
446
487
|
completed = []
|
|
488
|
+
gpio_log = []
|
|
447
489
|
try:
|
|
448
490
|
with httpx.Client(timeout=8.0) as client:
|
|
449
491
|
existing = client.get(f"{base}/list-motors").json().get("motors", [])
|
|
@@ -463,8 +505,22 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
463
505
|
})
|
|
464
506
|
response.raise_for_status()
|
|
465
507
|
deadline = time.monotonic() + len(registry) * ((duration_ms + pause_ms) / 1000.0) + 3.0
|
|
508
|
+
last_test_action = None
|
|
466
509
|
while time.monotonic() < deadline:
|
|
467
510
|
status = client.get(f"{base}/status").json()
|
|
511
|
+
action = str(status.get("last_action") or "")
|
|
512
|
+
if action.startswith("test:gpio:") and action != last_test_action:
|
|
513
|
+
try:
|
|
514
|
+
observed_gpio = int(action.rsplit(":", 1)[-1])
|
|
515
|
+
except ValueError:
|
|
516
|
+
observed_gpio = -1
|
|
517
|
+
if observed_gpio >= 2:
|
|
518
|
+
gpio_log.append({
|
|
519
|
+
"gpio": observed_gpio,
|
|
520
|
+
"status": "active_observed",
|
|
521
|
+
"elapsed_ms": int((time.monotonic() - started) * 1000),
|
|
522
|
+
})
|
|
523
|
+
last_test_action = action
|
|
468
524
|
if status.get("status") == "idle":
|
|
469
525
|
if status.get("error"):
|
|
470
526
|
raise RuntimeError(f"registered GPIO test failed: {status['error']}")
|
|
@@ -497,15 +553,17 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
497
553
|
time.sleep(0.05)
|
|
498
554
|
else:
|
|
499
555
|
raise TimeoutError(f"motor {motor_id} did not return to idle")
|
|
500
|
-
completed.append({"motor_id": motor_id, "duration_ms": duration_ms})
|
|
556
|
+
completed.append({"motor_id": motor_id, "gpio": int(motor["gpio"]), "duration_ms": duration_ms})
|
|
501
557
|
except Exception as exc:
|
|
502
558
|
try:
|
|
503
559
|
httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
|
|
504
560
|
except Exception:
|
|
505
561
|
pass
|
|
506
562
|
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
|
|
563
|
+
"gpio_log": gpio_log,
|
|
507
564
|
"duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
|
|
508
565
|
return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
|
|
566
|
+
"gpio_log": gpio_log,
|
|
509
567
|
"duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
|
|
510
568
|
"session_id": params.get("session_id")}
|
|
511
569
|
|