infinicode 2.8.104 → 2.8.107
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 +59 -27
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +25 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +17 -2
- package/packages/robopark/scheduler/preview_agent.py +28 -12
- package/packages/robopark/scheduler/robot_supervisor.py +18 -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 ── */
|
|
@@ -1264,8 +1264,24 @@ 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
|
+
}
|
|
1281
|
+
function schedulerHeartbeatFresh(device){
|
|
1282
|
+
var heartbeat=Date.parse(device&&device.last_heartbeat||'');
|
|
1283
|
+
return Number.isFinite(heartbeat)&&(Date.now()-heartbeat)<45000;
|
|
1284
|
+
}
|
|
1269
1285
|
var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)
|
|
1270
1286
|
var FALLBACK_SLOTS = [
|
|
1271
1287
|
{label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
|
|
@@ -1732,17 +1748,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1732
1748
|
// A stale re-enrollment can leave more than one row with the same
|
|
1733
1749
|
// display name. Keep the live/fresh device, never whichever SQLite
|
|
1734
1750
|
// 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
1751
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1742
1752
|
deviceById[device.id] = device;
|
|
1743
1753
|
if(device.name){
|
|
1744
1754
|
var key=norm(device.name), current=deviceByName[key];
|
|
1745
|
-
if(!current ||
|
|
1755
|
+
if(!current || schedulerDeviceScore(device)>schedulerDeviceScore(current)) deviceByName[key] = device;
|
|
1746
1756
|
}
|
|
1747
1757
|
});
|
|
1748
1758
|
schedulerDevicesById = deviceById;
|
|
@@ -2275,8 +2285,22 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2275
2285
|
argv.push('--token', meshToken);
|
|
2276
2286
|
if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);
|
|
2277
2287
|
argv.push(network==='tailscale'?'--tailscale':'--lan','--start','--auto-start','--yes');
|
|
2278
|
-
return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '"'+a.replace(/"/g,'\\"')+'"' : a; }).join(' ');
|
|
2279
|
-
}
|
|
2288
|
+
return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '"'+a.replace(/"/g,'\\"')+'"' : a; }).join(' ');
|
|
2289
|
+
}
|
|
2290
|
+
function buildPadRobotCommand(name,characterId,enrollmentToken){
|
|
2291
|
+
var env=rpCommandEnv(),hubHost=env.lan||'192.168.0.9';
|
|
2292
|
+
var hubUrl=/^https?:\/\//i.test(hubHost)?hubHost.replace(/\\\/+$/,''):'http://'+hubHost.replace(/\\\/+$/,'')+':47913';
|
|
2293
|
+
var meshToken=env.token||dashboardMeshToken()||'<mesh-token>',site=env.site||'Tel Aviv';
|
|
2294
|
+
var argv;
|
|
2295
|
+
if(enrollmentToken){
|
|
2296
|
+
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'];
|
|
2297
|
+
}else{
|
|
2298
|
+
argv=['robopark','add-robot',name];
|
|
2299
|
+
if(characterId)argv.push('--character',characterId);
|
|
2300
|
+
argv.push('--site',site,'--hub-url',hubUrl,'--token',meshToken,'--scheduler-url',hubUrl+'/robopark','--lan','--start','--auto-start','--yes');
|
|
2301
|
+
}
|
|
2302
|
+
return argv.map(function(a){return /[^a-zA-Z0-9_./:=,-]/.test(a)?'"'+String(a).replace(/"/g,'\\"')+'"':String(a);}).join(' ');
|
|
2303
|
+
}
|
|
2280
2304
|
function refreshRobotCliCommand(){
|
|
2281
2305
|
var name=$('rb-name').value.trim(); var cid=$('rb-char').value||'';
|
|
2282
2306
|
var row=$('rb-clicmd-row'), inp=$('rb-clicmd');
|
|
@@ -6809,7 +6833,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6809
6833
|
}
|
|
6810
6834
|
$('pk-name').textContent=n.label||n.name;
|
|
6811
6835
|
$('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
|
|
6812
|
-
var
|
|
6836
|
+
var initialDevice=schedulerDevice(n),initialHeartbeat=schedulerHeartbeatFresh(initialDevice);
|
|
6837
|
+
var st=$('pk-state'); st.textContent=initialHeartbeat?'live':(n.connected?'mesh only':'offline'); st.className='pk-pill '+(initialHeartbeat?'live':'idle');
|
|
6813
6838
|
buildDrawer(n);
|
|
6814
6839
|
pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
|
|
6815
6840
|
if(drawerTimer) clearInterval(drawerTimer);
|
|
@@ -6881,11 +6906,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6881
6906
|
livekit_url:$('pk-setup-lk').value.trim()||null,
|
|
6882
6907
|
video_device:'auto', audio_device:'default'
|
|
6883
6908
|
};
|
|
6884
|
-
rpAction('POST','/robopark/api/devices',body).then(function(j){
|
|
6885
|
-
var token=(j&&j.enrollment_token)||'';
|
|
6886
|
-
$('pk-setup-token').value=token;
|
|
6887
|
-
$('pk-setup-result').style.display='block';
|
|
6888
|
-
|
|
6909
|
+
rpAction('POST','/robopark/api/devices',body).then(function(j){
|
|
6910
|
+
var token=(j&&j.enrollment_token)||'';
|
|
6911
|
+
$('pk-setup-token').value=token;
|
|
6912
|
+
$('pk-setup-result').style.display='block';
|
|
6913
|
+
var command=$('pk-setup-clicmd'),label=$('pk-setup-clicmd-label');
|
|
6914
|
+
if(command)command.value=buildPadRobotCommand(name,body.character_id||n.id,token);
|
|
6915
|
+
if(label)label.textContent='run this ON THE ROBOT (uses the token minted above)';
|
|
6916
|
+
showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));
|
|
6889
6917
|
pollRoboparkQuiet();
|
|
6890
6918
|
}).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });
|
|
6891
6919
|
}
|
|
@@ -7017,14 +7045,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7017
7045
|
function schedulerDevice(n){
|
|
7018
7046
|
// Deliberately does not call schedRow: telemetry can contain a legacy
|
|
7019
7047
|
// robot name, while this lookup must choose the current enrolled device.
|
|
7048
|
+
var candidates=[];
|
|
7020
7049
|
var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];
|
|
7021
|
-
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]])
|
|
7050
|
+
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) candidates.push(schedulerDevicesById[ids[i]]);
|
|
7022
7051
|
var names=[n.name,n.displayName,n.label];
|
|
7023
7052
|
for(var j=0;j<names.length;j++){
|
|
7024
7053
|
var key=String(names[j]||'').toLowerCase();
|
|
7025
|
-
if(key&&schedulerDevicesByName[key])
|
|
7054
|
+
if(key&&schedulerDevicesByName[key]) candidates.push(schedulerDevicesByName[key]);
|
|
7026
7055
|
}
|
|
7027
|
-
return
|
|
7056
|
+
candidates=candidates.filter(function(device,index,list){return device&&list.indexOf(device)===index;});
|
|
7057
|
+
candidates.sort(function(a,b){return schedulerDeviceScore(b)-schedulerDeviceScore(a);});
|
|
7058
|
+
return candidates[0]||null;
|
|
7028
7059
|
}
|
|
7029
7060
|
function deviceList(inv, key, fallback){
|
|
7030
7061
|
var list=inv&&Array.isArray(inv[key])?inv[key]:[];
|
|
@@ -7423,7 +7454,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7423
7454
|
+ '<div class="rp-field"><label>Robot name</label><input id="pk-setup-name" value="'+esc(defaultName)+'"></div>'
|
|
7424
7455
|
+ '<div class="rp-field"><label>Character preset</label><select id="pk-setup-char"><option value="">— auto —</option>'+presetOptions+'</select></div>'
|
|
7425
7456
|
+ '<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>'
|
|
7426
|
-
+ '<div class="rp-field"><label>or run this ON THE ROBOT (mints its own token)</label><
|
|
7457
|
+
+ '<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>'
|
|
7427
7458
|
+ '<div class="rp-quickrow" style="padding:0;"><button class="rp-btn" id="pk-setup-clicmd-copy" style="width:fit-content;">📋 Copy command</button></div>'
|
|
7428
7459
|
+ '<details class="rp-advanced"><summary>Advanced — pre-set network/voice config</summary>'
|
|
7429
7460
|
+ '<div class="rp-advanced-grid">'
|
|
@@ -7437,10 +7468,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7437
7468
|
+ '<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>');
|
|
7438
7469
|
var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });
|
|
7439
7470
|
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'); } });
|
|
7440
|
-
function refreshPadCliCommand(){
|
|
7441
|
-
var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;
|
|
7442
|
-
var el=$('pk-setup-clicmd');
|
|
7443
|
-
}
|
|
7471
|
+
function refreshPadCliCommand(){
|
|
7472
|
+
var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;
|
|
7473
|
+
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)';
|
|
7474
|
+
}
|
|
7444
7475
|
var padNameEl=$('pk-setup-name'); if(padNameEl) padNameEl.addEventListener('input', refreshPadCliCommand);
|
|
7445
7476
|
var padCharEl=$('pk-setup-char'); if(padCharEl) padCharEl.addEventListener('change', refreshPadCliCommand);
|
|
7446
7477
|
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'); });
|
|
@@ -7808,7 +7839,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7808
7839
|
set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
|
|
7809
7840
|
var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
|
|
7810
7841
|
var active=row.active_session||row.in_session||row.session_active||row.current_session_id||row.status==='connecting'||row.status==='running';
|
|
7811
|
-
var
|
|
7842
|
+
var heartbeatLive=schedulerHeartbeatFresh(schedulerDevice(n));
|
|
7843
|
+
var st=$('pk-state'); if(st){ st.textContent=active?'in session':(heartbeatLive?'live':(n.connected?'mesh only':'offline')); st.className='pk-pill '+(active||heartbeatLive?'live':'idle'); }
|
|
7812
7844
|
}
|
|
7813
7845
|
}
|
|
7814
7846
|
function set(id,v){ var e=$(id); if(e) e.textContent=v; }
|
|
@@ -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()
|
|
@@ -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,26 @@ 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
|
-
|
|
637
|
+
try:
|
|
638
|
+
self.device_id, self.device_token = await _enroll(
|
|
639
|
+
self.scheduler_url, enrollment_token, self.robot_id
|
|
640
|
+
)
|
|
641
|
+
self._mesh_bootstrap_ready = True
|
|
642
|
+
except Exception as exc:
|
|
643
|
+
if not _mesh_proxy_headers():
|
|
644
|
+
raise
|
|
645
|
+
# Enrollment tokens are intentionally one-time. A service
|
|
646
|
+
# reinstall can retain the original systemd argument after
|
|
647
|
+
# its credential file was removed; recover through the hub's
|
|
648
|
+
# authenticated mesh path rather than crash-loop forever.
|
|
649
|
+
logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
|
|
650
|
+
await self._ensure_mesh_identity()
|
|
651
|
+
elif _mesh_proxy_headers():
|
|
652
|
+
await self._ensure_mesh_identity()
|
|
637
653
|
|
|
638
654
|
if not self.device_token:
|
|
639
655
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
@@ -485,6 +485,7 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
485
485
|
steps = params.get("steps") or []
|
|
486
486
|
started = time.monotonic()
|
|
487
487
|
completed = []
|
|
488
|
+
gpio_log = []
|
|
488
489
|
try:
|
|
489
490
|
with httpx.Client(timeout=8.0) as client:
|
|
490
491
|
existing = client.get(f"{base}/list-motors").json().get("motors", [])
|
|
@@ -504,8 +505,22 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
504
505
|
})
|
|
505
506
|
response.raise_for_status()
|
|
506
507
|
deadline = time.monotonic() + len(registry) * ((duration_ms + pause_ms) / 1000.0) + 3.0
|
|
508
|
+
last_test_action = None
|
|
507
509
|
while time.monotonic() < deadline:
|
|
508
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
|
|
509
524
|
if status.get("status") == "idle":
|
|
510
525
|
if status.get("error"):
|
|
511
526
|
raise RuntimeError(f"registered GPIO test failed: {status['error']}")
|
|
@@ -538,15 +553,17 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
538
553
|
time.sleep(0.05)
|
|
539
554
|
else:
|
|
540
555
|
raise TimeoutError(f"motor {motor_id} did not return to idle")
|
|
541
|
-
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})
|
|
542
557
|
except Exception as exc:
|
|
543
558
|
try:
|
|
544
559
|
httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
|
|
545
560
|
except Exception:
|
|
546
561
|
pass
|
|
547
562
|
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
|
|
563
|
+
"gpio_log": gpio_log,
|
|
548
564
|
"duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
|
|
549
565
|
return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
|
|
566
|
+
"gpio_log": gpio_log,
|
|
550
567
|
"duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
|
|
551
568
|
"session_id": params.get("session_id")}
|
|
552
569
|
|