infinicode 2.6.1 → 2.7.0

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.
@@ -124,6 +124,25 @@ section h2 .count{color:var(--muted);}
124
124
  .cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}
125
125
  .empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
126
126
 
127
+ /* fleet card — live per-robot hardware */
128
+ .statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}
129
+ .statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}
130
+ .statdot.off{background:var(--bad);}
131
+ .node .mrow{display:grid;grid-template-columns:2.3rem 1fr 2.6rem;align-items:center;gap:0.45rem;margin-top:0.42rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
132
+ .node .mrow .ml{letter-spacing:0.04em;}
133
+ .node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}
134
+ .node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}
135
+ .node .mrow .bar-track{margin:0;}
136
+ .node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}
137
+ .node .stats{display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-top:0.5rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
138
+ .node .stats span b{color:var(--ink);font-weight:600;}
139
+ .node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}
140
+ .tbadge{font-family:var(--mono);font-size:0.6rem;padding:0.08em 0.45em;border-radius:5px;border:1px solid var(--glass-border);color:var(--muted);}
141
+ .tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}
142
+ .tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
143
+ .chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
144
+ .chip.alert b{color:var(--bad);}
145
+
127
146
  /* hardware meters */
128
147
  .hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
129
148
  background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
@@ -443,6 +462,45 @@ button.act:disabled{opacity:0.5;cursor:default;}
443
462
  + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div></div>';
444
463
  }
445
464
 
465
+ // ── per-node hardware store + rolling history (drives the Fleet cards + sparklines) ──
466
+ var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)
467
+ var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers
468
+ var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence
469
+ function netTotal(hw){
470
+ var t=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ var f=hw.network.interfaces[i]; t+=(f.rxBytesPerSec||0)+(f.txBytesPerSec||0); } } return t;
471
+ }
472
+ function netRxTx(hw){
473
+ var rx=0,tx=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ rx+=hw.network.interfaces[i].rxBytesPerSec||0; tx+=hw.network.interfaces[i].txBytesPerSec||0; } } return {rx:rx,tx:tx};
474
+ }
475
+ function pushHist(id, snap){
476
+ var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});
477
+ var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;
478
+ var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;
479
+ h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();
480
+ h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();
481
+ h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();
482
+ }
483
+ function updateHw(s){
484
+ if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }
485
+ var act = s.activity||[];
486
+ for(var i=0;i<act.length;i++){ var f=(act[i].f||act[i]); if(f.t==='hw'&&f.n&&f.d){ hwByNode[f.n]=f.d; } }
487
+ var nodes = s.nodes||[];
488
+ for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }
489
+ }
490
+ // Inline sparkline (SVG polyline) from a numeric history buffer.
491
+ function sparkSvg(arr, color, maxv){
492
+ if(!arr || arr.length<2) return '';
493
+ var w=100,h=20,n=arr.length, vals=[];
494
+ for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);
495
+ var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;
496
+ var pts=''; for(var j=0;j<n;j++){ var x=(j/(n-1))*w; var y=h-(vals[j]/mx)*(h-2)-1; pts+=(j?' ':'')+x.toFixed(1)+','+y.toFixed(1); }
497
+ return '<svg class="spark" viewBox="0 0 '+w+' '+h+'" preserveAspectRatio="none" aria-hidden="true">'
498
+ + '<polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.6" stroke-linejoin="round"/></svg>';
499
+ }
500
+ function fmtRate(b){ return fmtBytes(b)+'/s'; }
501
+ function fmtUptime(sec){ if(sec==null) return '—'; var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60); if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; return m+'m'; }
502
+ function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }
503
+
446
504
  function renderHw(hw){
447
505
  var el = $('hw');
448
506
  if(!hw){ el.innerHTML = '<div class="meter"><div class="lab"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }
@@ -450,6 +508,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
450
508
  html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');
451
509
  html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');
452
510
  if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }
511
+ if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'°C'); }
512
+ if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]){ var mnt=hw.disk.mounts[0]; html += meter('Disk '+esc(mnt.path), mnt.percent, fmtBytes(mnt.usedBytes)+' / '+fmtBytes(mnt.totalBytes)); }
513
+ var nt=netRxTx(hw); html += '<div class="stats" style="margin-top:0.55rem">'
514
+ + '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span><span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
515
+ + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';
453
516
  el.innerHTML = html;
454
517
  }
455
518
 
@@ -460,25 +523,57 @@ button.act:disabled{opacity:0.5;cursor:default;}
460
523
  for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);
461
524
  }
462
525
 
526
+ function mrow(label, pct, valTxt, cls){
527
+ var v = Math.max(0, Math.min(100, Math.round(pct||0)));
528
+ return '<div class="mrow"><span class="ml">'+label+'</span>'
529
+ + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div>'
530
+ + '<span class="mv '+(cls||'')+'">'+esc(valTxt)+'</span></div>';
531
+ }
463
532
  function renderFleet(nodes){
464
533
  var el = $('fleet');
465
534
  if(!nodes || !nodes.length){ el.innerHTML = '<div class="empty">No nodes reporting yet. Start peers with <span class="mono">infinicode serve</span> or <span class="mono">robopark setup</span>.</div>'; return; }
535
+ // connected first, then robots/satellites above hubs so the fleet reads robot-first
466
536
  nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });
537
+ var accent = '#6FE0A6';
538
+ try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}
467
539
  var html = '';
468
540
  for(var i=0;i<nodes.length;i++){
469
541
  var n = nodes[i];
470
542
  var isSelf = n.nodeId===selfId;
471
- var load = n.load!=null?n.load:0;
472
- var caps = (n.capabilities||[]).slice(0,6);
543
+ var up = !!n.connected;
544
+ var hw = hwByNode[n.nodeId] || null;
545
+ var hist = hwHist[n.nodeId] || {};
546
+ var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);
547
+ var cores = hw&&hw.cpu ? hw.cpu.cores : null;
548
+ var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;
549
+ var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;
550
+ var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;
551
+ var nt = netRxTx(hw);
552
+ var upt = hw&&hw.system ? hw.system.uptimeSec : null;
553
+ var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);
473
554
  var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
474
- var site = n.site?('· '+esc(n.site)):'';
475
- html += '<div class="node '+(n.connected?'up':'down')+(selected===n.nodeId?' sel':'')+'" tabindex="0" role="button" data-id="'+esc(n.nodeId)+'" data-name="'+esc(n.displayName)+'">'
476
- + '<div class="top"><span class="name">'+esc(n.displayName)+(isSelf?' <span class="selfbadge">this</span>':'')+'</span><span class="role">'+esc(n.role)+'</span></div>'
477
- + '<div class="meta">'+esc(n.platform||'')+' '+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>'
478
- + '<div class="bar-track"><div class="bar-fill '+loadClass(load)+'" style="width:'+(n.connected?load:0)+'%"></div></div>'
479
- + '<div class="load"><span>'+(n.connected?'load '+Math.round(load)+'%':'offline')+'</span><span>'+((n.commands||[]).length)+' cmds</span></div>'
480
- + (capHtml?('<div class="caps">'+capHtml+'</div>'):'')
481
- + '</div>';
555
+ var site = n.site?(' · '+esc(n.site)):'';
556
+
557
+ html += '<div class="node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'" tabindex="0" role="button" data-id="'+esc(n.nodeId)+'" data-name="'+esc(n.displayName)+'">'
558
+ + '<div class="top"><span class="name"><span class="statdot '+(up?'on':'off')+'"></span>'+esc(n.displayName)+(isSelf?' <span class="selfbadge">this</span>':'')+'</span><span class="role">'+esc(n.role)+'</span></div>'
559
+ + '<div class="meta">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>';
560
+
561
+ if(up){
562
+ html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))
563
+ + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')
564
+ + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')
565
+ + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'°C', tempClass(temp)) : '')
566
+ + '<div class="stats">'
567
+ + '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span>'
568
+ + '<span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
569
+ + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')
570
+ + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')
571
+ + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'
572
+ + '</div>';
573
+ } else {
574
+ html += '<div class="stats"><span style="color:var(--muted)">offline'+(n.lastSeenAt?(' · last seen '+clock(n.lastSeenAt)):'')+'</span></div>';
575
+ }
576
+ html += (capHtml?('<div class="caps">'+capHtml+'</div>'):'') + '</div>';
482
577
  }
483
578
  el.innerHTML = html;
484
579
  var cards = el.querySelectorAll('.node');
@@ -498,11 +593,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
498
593
  var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;
499
594
  var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});
500
595
  var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;
501
- $('summary').innerHTML =
502
- '<span class="chip ok">nodes up <b>'+up+'</b></span>'
596
+ // live aggregates + alerts from per-node hardware
597
+ var hotTemp=null, maxCpu=0, alerts=[];
598
+ for(var k=0;k<nodes.length;k++){
599
+ var nn=nodes[k]; if(!nn.connected) continue;
600
+ var hw=hwByNode[nn.nodeId]; if(!hw) continue;
601
+ if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);
602
+ if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;
603
+ if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'°C');
604
+ if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');
605
+ if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');
606
+ if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]&&hw.disk.mounts[0].percent>=90) alerts.push(nn.displayName+' disk '+hw.disk.mounts[0].percent+'%');
607
+ }
608
+ if(down>0) alerts.unshift(down+' offline');
609
+ var html = '<span class="chip ok">up <b>'+up+'</b></span>'
503
610
  + '<span class="chip'+(down?' bad':'')+'">down <b>'+down+'</b></span>'
504
- + '<span class="chip info">active runs <b>'+runs+'</b></span>'
505
- + '<span class="chip">avg load <b>'+avg+'%</b></span>';
611
+ + '<span class="chip info">runs <b>'+runs+'</b></span>'
612
+ + '<span class="chip">avg load <b>'+avg+'%</b></span>'
613
+ + '<span class="chip">peak cpu <b>'+maxCpu+'%</b></span>'
614
+ + (hotTemp!=null?('<span class="chip">hottest <b>'+Math.round(hotTemp)+'°C</b></span>'):'');
615
+ for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class="chip alert">⚠ <b>'+esc(alerts[a2])+'</b></span>'; }
616
+ $('summary').innerHTML = html;
506
617
  $('fleetcount').textContent = '(' + nodes.length + ')';
507
618
  var self = s.self||{};
508
619
  var role = s.role||{};
@@ -522,6 +633,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
522
633
  everOk = true; setLive(true); lastStatus = s;
523
634
  if(!selfId && s.self) selfId = s.self.nodeId;
524
635
  if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
636
+ updateHw(s);
525
637
  renderSummary(s);
526
638
  renderFleet(s.nodes||[]);
527
639
  renderHw(s.hardware);
@@ -705,13 +817,28 @@ button.act:disabled{opacity:0.5;cursor:default;}
705
817
  // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
706
818
  // ══════════════════════════════════════════════════════════════════
707
819
  var Park = (function(){
708
- var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1};
820
+ var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
709
821
  var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
710
- var TW=124, TH=62;
711
822
  var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
712
- var RING=[[2,0],[1,2],[-1,2],[-2,0],[-1,-2],[1,-2],[3,1],[3,-1],[0,3],[-3,1],[-3,-1],[0,-3],[2,3],[-2,3],[2,-3],[-2,-3],[4,0],[0,4],[-4,0],[0,-4]];
823
+ var lkRoom=null, micAnalyser=null, audioCtx=null;
824
+ var TW=44, TH=22, PARK_W=15, PARK_D=8;
825
+ // The real park (from the aerial): named robot pads at their ground
826
+ // positions, plus palms, pond, and the street. A mesh node binds to a pad
827
+ // when its display name matches the pad keywords (robobmw → VIXEN/BMW).
828
+ var SLOTS=[
829
+ {label:'VOLT', keys:['volt'], nx:0.30, ny:0.22},
830
+ {label:'TESLA', keys:['tesla'], nx:0.44, ny:0.36},
831
+ {label:'JAGUAR', keys:['jaguar','jag'], nx:0.60, ny:0.40},
832
+ {label:'VIXEN', keys:['vixen','bmw'], nx:0.55, ny:0.66, sub:'BMW'},
833
+ {label:'PANDA', keys:['panda'], nx:0.84, ny:0.58}
834
+ ];
835
+ var HUBSLOT={label:'CONTROL', keys:[], nx:0.09, ny:0.12};
836
+ var PALMS=[[0.11,0.55],[0.15,0.74],[0.19,0.42],[0.23,0.64],[0.17,0.31],[0.25,0.82],[0.37,0.24],[0.41,0.56],[0.49,0.74],[0.52,0.29],[0.59,0.62],[0.66,0.26],[0.71,0.69],[0.76,0.42],[0.82,0.74],[0.89,0.42],[0.92,0.68],[0.67,0.83],[0.34,0.71]];
837
+ var POND={cx:0.33*15, cy:0.99*8, rx:3.1, ry:1.4};
838
+ var CARS=[[4,-0.95,'#e0a23c'],[6,-0.8,'#7fb4ff'],[8,-1.0,'#ff7e6e'],[10.5,-0.78,'#d8d8e0'],[12.8,-0.95,'#f2c14e']];
713
839
 
714
840
  function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
841
+ function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
715
842
  function kindOf(n){
716
843
  var r=(n.role||'').toLowerCase();
717
844
  if(r==='hub'||r==='scheduler') return 'hub';
@@ -723,19 +850,41 @@ button.act:disabled{opacity:0.5;cursor:default;}
723
850
  function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
724
851
  function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
725
852
 
853
+ // Bind mesh nodes onto the fixed park pads; unmatched pads render as
854
+ // offline placeholders so the whole park is always visible.
726
855
  function layout(src){
727
- var list=(src||[]).slice().sort(function(a,b){ return (a.nodeId<b.nodeId?-1:1); });
728
- var hub=null; for(var i=0;i<list.length;i++){ if(kindOf(list[i])==='hub'){ hub=list[i]; break; } }
729
- if(!hub && list.length) hub=list[0];
730
- var out=[], oi=0;
731
- list.forEach(function(n){
732
- var cell = (n===hub)? [0,0] : RING[(oi++)%RING.length];
733
- var prev = byId[n.nodeId];
734
- out.push({
735
- id:n.nodeId, name:n.displayName||n.nodeId, role:n.role||'?', kind:kindOf(n),
736
- connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
737
- site:n.site||'', caps:n.capabilities||[], gx:cell[0], gy:cell[1], _hit:prev?prev._hit:null
738
- });
856
+ var list=(src||[]).slice(), used={}, out=[];
857
+ function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
858
+ function bind(slot, wx, wy, kind){
859
+ var match=null;
860
+ for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;
861
+ var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';
862
+ if(kind==='hub'){ if(isHub){ match=n; break; } }
863
+ else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }
864
+ }
865
+ var id = match?match.nodeId:('pad:'+slot.label);
866
+ if(match) used[match.nodeId]=1;
867
+ return {
868
+ id:id, kind:kind, label:slot.label,
869
+ name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
870
+ sub: match?(slot.sub||''):'not joined',
871
+ connected: match?!!match.connected:false,
872
+ load: (match&&match.load!=null)?match.load:0,
873
+ platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
874
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
875
+ telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
876
+ wx:wx, wy:wy, _hit:prevHit(id)
877
+ };
878
+ }
879
+ var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
880
+ SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
881
+ // Any extra live node that matched no pad → line it along the near edge.
882
+ var ex=0;
883
+ list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;
884
+ var w=W2(0.20+ex*0.13, 0.90); ex++;
885
+ out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),
886
+ sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
887
+ site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });
739
888
  });
740
889
  return out;
741
890
  }
@@ -750,8 +899,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
750
899
 
751
900
  function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
752
901
  function findByFrame(f){
753
- var key=f.n; if(key==null) return null;
754
- for(var i=0;i<nodes.length;i++){ if(nodes[i].name===key||nodes[i].id===key) return nodes[i]; }
902
+ var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;
903
+ for(var i=0;i<nodes.length;i++){ var n=nodes[i];
904
+ if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;
905
+ for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }
906
+ }
755
907
  return null;
756
908
  }
757
909
  function ingest(f, fromPoll){
@@ -797,30 +949,67 @@ button.act:disabled{opacity:0.5;cursor:default;}
797
949
  C.addEventListener('click',function(e){
798
950
  var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
799
951
  for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }
800
- if(hit){ openDrawer(hit.id); selectNode(hit.id, hit.name); }
952
+ if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
801
953
  });
802
954
  window.addEventListener('resize', function(){ if(running) resize(); });
803
955
  }
804
956
  function resize(){
805
957
  var st=$('pk-stage'); if(!st) return;
806
958
  W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
959
+ if(!fitted){ fit(); fitted=true; }
807
960
  }
808
- function iso(gx,gy){ return { x: W/2 + cam.x + (gx-gy)*TW/2*cam.z, y: H*0.42 + cam.y + (gx+gy)*TH/2*cam.z }; }
809
-
810
- function tile(gx,gy,raised){
811
- var p=iso(gx,gy), z=cam.z, hw=TW/2*z, hh=TH/2*z, dz=raised?13*z:0;
812
- X.beginPath(); X.moveTo(p.x,p.y-hh-dz); X.lineTo(p.x+hw,p.y-dz); X.lineTo(p.x,p.y+hh-dz); X.lineTo(p.x-hw,p.y-dz); X.closePath();
813
- var g=X.createLinearGradient(p.x-hw,p.y-hh,p.x+hw,p.y+hh);
814
- g.addColorStop(0,raised?'rgba(60,52,86,0.55)':'rgba(30,26,44,0.32)'); g.addColorStop(1,raised?'rgba(20,17,32,0.6)':'rgba(16,14,26,0.28)');
815
- X.fillStyle=g; X.fill();
816
- X.strokeStyle=raised?'rgba(255,214,112,0.55)':'rgba(255,255,255,0.08)'; X.lineWidth=1; X.stroke();
817
- if(raised){
818
- X.beginPath(); X.moveTo(p.x-hw,p.y-dz); X.lineTo(p.x-hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
819
- X.fillStyle='rgba(10,8,18,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.28)'; X.stroke();
820
- X.beginPath(); X.moveTo(p.x+hw,p.y-dz); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
821
- X.fillStyle='rgba(14,11,24,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.2)'; X.stroke();
822
- }
823
- return p;
961
+ function fit(){
962
+ var span=PARK_W+PARK_D;
963
+ var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);
964
+ cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;
965
+ }
966
+ // World (park) coords → screen. Origin biased left so the right-docked feed
967
+ // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).
968
+ function iso(wx,wy){
969
+ var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);
970
+ return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };
971
+ }
972
+ function poly(pts, fill, stroke, lw){
973
+ X.beginPath();
974
+ for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }
975
+ X.closePath();
976
+ if(fill){ X.fillStyle=fill; X.fill(); }
977
+ if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
978
+ }
979
+ function drawGround(){
980
+ // grass base
981
+ var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');
982
+ poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);
983
+ // street band along the far edge + dashed centerline + cars
984
+ poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);
985
+ X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);
986
+ var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);
987
+ CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
988
+ // paved plaza
989
+ var px0=0.4,py0=-0.15,px1=PARK_W-0.4,py1=PARK_D-0.6;
990
+ var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');
991
+ poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);
992
+ X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;
993
+ for(var gx=Math.ceil(px0);gx<px1;gx++){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }
994
+ for(var gy=Math.ceil(py0);gy<py1;gy++){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }
995
+ // pond
996
+ var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/9){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)]); }
997
+ var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');
998
+ poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);
999
+ }
1000
+ function drawCar(wx,wy,col){
1001
+ var p=iso(wx,wy), z=cam.z;
1002
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();
1003
+ X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();
1004
+ X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();
1005
+ }
1006
+ function drawPalm(wx,wy){
1007
+ var p=iso(wx,wy), z=cam.z;
1008
+ X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();
1009
+ X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();
1010
+ var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;
1011
+ for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }
1012
+ X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();
824
1013
  }
825
1014
  function ring(cx,cy,r,frac,color){
826
1015
  X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();
@@ -831,62 +1020,68 @@ button.act:disabled{opacity:0.5;cursor:default;}
831
1020
  X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
832
1021
  X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
833
1022
  }
1023
+ function padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }
834
1024
  function drawHub(n){
835
- var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-14*z-bob;
836
- X.fillStyle='rgba(40,34,58,0.9)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
1025
+ var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;
1026
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();
1027
+ X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
837
1028
  X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
838
1029
  for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }
839
1030
  X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
840
1031
  X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
841
- label(bx,p.y+18*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
1032
+ label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
842
1033
  n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
843
1034
  }
844
1035
  function drawRobot(n){
845
- var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/500+n.gx)*2.4*z, bx=p.x, by=p.y-10*z-bob;
846
- ring(bx,p.y+2*z,20*z,n.load/100,loadColor(n.load));
847
- X.fillStyle='rgba(0,0,0,0.35)'; X.beginPath(); X.ellipse(bx,p.y+2*z,15*z,7*z,0,0,7); X.fill();
848
- var col=n.connected?getVar('--gold-bright'):'rgba(150,140,120,0.5)';
849
- X.fillStyle='rgba(24,20,38,0.9)'; X.strokeStyle=col; X.lineWidth=1.6;
850
- X.beginPath(); X.roundRect(bx-12*z,by-24*z,24*z,26*z,5*z); X.fill(); X.stroke();
851
- X.beginPath(); X.roundRect(bx-9*z,by-38*z,18*z,15*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
852
- X.fillStyle=n.connected?getVar('--ok'):'#654'; X.beginPath(); X.arc(bx-4*z,by-30*z,2.1*z,0,7); X.arc(bx+4*z,by-30*z,2.1*z,0,7); X.fill();
853
- X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-38*z); X.lineTo(bx,by-45*z); X.stroke();
854
- X.fillStyle=n.connected?'#FF5B4A':'#654'; X.beginPath(); X.arc(bx,by-46*z,2.2*z,0,7); X.fill();
855
- label(bx,p.y+18*z,n.name,col,(n.connected?'load '+Math.round(n.load)+'%':'offline'));
856
- n._hit={x:bx-16*z,y:by-48*z,w:32*z,h:62*z};
1036
+ var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;
1037
+ if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);
1038
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();
1039
+ var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
1040
+ X.globalAlpha=n.connected?1:0.7;
1041
+ X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;
1042
+ X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();
1043
+ X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
1044
+ X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();
1045
+ X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();
1046
+ X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();
1047
+ X.globalAlpha=1;
1048
+ var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' · ':'')+Math.round(n.load)+'%'):'not joined';
1049
+ label(bx,p.y+17*z,n.label,col,sub);
1050
+ n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};
857
1051
  }
858
1052
  function drawPeer(n){
859
- var p=tile(n.gx,n.gy,true), z=cam.z, bx=p.x, by=p.y-8*z;
860
- var col=n.connected?getVar('--info'):'rgba(150,140,120,0.5)';
1053
+ var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
1054
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();
1055
+ var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';
861
1056
  X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
862
1057
  X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
863
1058
  X.fillStyle=col; X.globalAlpha=0.5+0.5*Math.abs(Math.sin((performance.now()-t0)/500)); X.fillRect(bx-8*z,by-14*z,16*z,3*z); X.globalAlpha=1;
864
- label(bx,p.y+16*z,n.name,col,(n.connected?n.role:'offline'));
1059
+ label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));
865
1060
  n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
866
1061
  }
867
1062
 
868
1063
  function frame(){
869
1064
  if(!running) return;
870
1065
  X.clearRect(0,0,W,H);
871
- for(var gx=-4;gx<=4;gx++) for(var gy=-4;gy<=4;gy++){ if(!nodes.some(function(n){return n.gx===gx&&n.gy===gy;})) faint(gx,gy); }
872
- var sorted=nodes.slice().sort(function(a,b){ return (a.gx+a.gy)-(b.gx+b.gy); });
873
- sorted.forEach(function(n){ if(n.kind==='hub') drawHub(n); else if(n.kind==='robot') drawRobot(n); else drawPeer(n); });
874
- for(var i=orbs.length-1;i>=0;i--){ var o=orbs[i]; o.t+=0.024; if(o.t>=1){orbs.splice(i,1);continue;}
875
- var a=iso(o.a.gx,o.a.gy), b=iso(o.b.gx,o.b.gy), e=o.t<.5?2*o.t*o.t:-1+(4-2*o.t)*o.t;
876
- var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o.t*Math.PI)*40*cam.z;
877
- X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o.color; X.shadowColor=o.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
1066
+ drawGround();
1067
+ // depth-sort palms + nodes so nearer objects overlap farther ones
1068
+ var objs=[];
1069
+ for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }
1070
+ for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
1071
+ objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
1072
+ objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); } });
1073
+ for(var i=orbs.length-1;i>=0;i--){ var o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}
1074
+ var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;
1075
+ var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;
1076
+ X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
878
1077
  }
879
1078
  for(var j=pulses.length-1;j>=0;j--){ var pu=pulses[j]; pu.t+=0.02; if(pu.t>=1){pulses.splice(j,1);continue;}
880
- var pp=iso(pu.n.gx,pu.n.gy), yy=pp.y-58*cam.z-pu.t*24*cam.z;
1079
+ var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;
881
1080
  X.globalAlpha=1-pu.t; X.font='700 '+(15*cam.z)+'px '+mono(); X.textAlign='center'; X.fillStyle=getVar('--gold-bright'); X.fillText(pu.g,pp.x,yy);
882
- X.beginPath(); X.arc(pp.x,pp.y-20*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
1081
+ X.beginPath(); X.arc(pp.x,pp.y-18*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
883
1082
  }
884
1083
  raf=requestAnimationFrame(frame);
885
1084
  }
886
- function faint(gx,gy){ var p=iso(gx,gy),z=cam.z,hw=TW/2*z,hh=TH/2*z;
887
- X.beginPath(); X.moveTo(p.x,p.y-hh); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x-hw,p.y); X.closePath();
888
- X.strokeStyle='rgba(255,255,255,0.045)'; X.lineWidth=1; X.stroke();
889
- }
890
1085
 
891
1086
  function connectStream(){
892
1087
  if(es) return;
@@ -898,8 +1093,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
898
1093
  }catch(e){ es=null; }
899
1094
  }
900
1095
 
1096
+ var sdkLoading=false;
1097
+ function loadSDK(){
1098
+ if(window.LivekitClient || sdkLoading) return; sdkLoading=true;
1099
+ var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;
1100
+ s.onerror=function(){ sdkLoading=false; }; document.head.appendChild(s);
1101
+ }
901
1102
  function activate(){
902
1103
  ensure();
1104
+ loadSDK();
903
1105
  if(lastStatus) onStatus(lastStatus);
904
1106
  resize();
905
1107
  connectStream();
@@ -913,9 +1115,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
913
1115
  var n=byId[id]; if(!n) return; drawerId=id;
914
1116
  $('pk-drawer').classList.add('open');
915
1117
  $('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
916
- $('pk-name').textContent=n.name;
917
- $('pk-sub').textContent=n.role+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'');
918
- var st=$('pk-state'); st.textContent=n.connected?'live':'idle'; st.className='pk-pill '+(n.connected?'live':'idle');
1118
+ $('pk-name').textContent=n.label||n.name;
1119
+ $('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
1120
+ var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
919
1121
  buildDrawer(n);
920
1122
  pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
921
1123
  if(drawerTimer) clearInterval(drawerTimer);
@@ -927,6 +1129,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
927
1129
  if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
928
1130
  if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
929
1131
  if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
1132
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1133
+ micAnalyser=null;
930
1134
  }
931
1135
  function pollRoboparkQuiet(){
932
1136
  fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
@@ -937,6 +1141,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
937
1141
  }
938
1142
  function buildDrawer(n){
939
1143
  var b=$('pk-body');
1144
+ if(n.kind==='robot' && !n.connected){
1145
+ b.innerHTML =
1146
+ sect('🅿 park pad · '+esc(n.label), '<div class="pk-note" style="opacity:1;color:var(--muted)">No robot has joined this pad yet. When a node named <b>'+esc((n.telemKeys[1]||n.label).toString())+'</b> connects to the mesh, its live camera, mic, speaker, and telemetry appear here automatically.</div>')
1147
+ + sect('📍 location', '<div class="pk-grid2"><div class="pk-kv"><div class="v">'+esc(n.label)+'</div><div class="l">pad</div></div><div class="pk-kv"><div class="v" style="color:var(--muted)">idle</div><div class="l">status</div></div></div>');
1148
+ return;
1149
+ }
940
1150
  if(n.kind==='robot'){
941
1151
  b.innerHTML =
942
1152
  sect('👁 camera feed',
@@ -966,6 +1176,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
966
1176
  var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
967
1177
  var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
968
1178
  var row=roboparkByName[n.name];
1179
+ if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }
969
1180
  if(row){
970
1181
  set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
971
1182
  set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
@@ -980,30 +1191,54 @@ button.act:disabled{opacity:0.5;cursor:default;}
980
1191
 
981
1192
  // ── cam: target a LiveKit track; fall back to a styled preview ──
982
1193
  function startCam(n){
983
- var note=$('pk-camnote'), res=$('pk-camres');
1194
+ var note=$('pk-camnote');
984
1195
  camAbort = ('AbortController' in window)?new AbortController():null;
985
- // The robot-side RoboPark plugin exposes this; returns {url, token, room, trackSid}.
1196
+ idleCam('connecting');
1197
+ // scheduler mints a subscribe-only viewer token for the robot's current
1198
+ // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).
986
1199
  fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
987
- .then(function(r){ if(!r.ok) throw new Error('no stream'); return r.json(); })
1200
+ .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
988
1201
  .then(function(info){
989
- if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); }
990
- else if(info && info.url){ if(note) note.textContent='LiveKit track ready — load livekit-client to view'; previewCam(n); }
991
- else throw new Error('no track');
1202
+ if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
1203
+ if(info && info.url && info.token){
1204
+ if(window.LivekitClient){ attachLiveKit(info, n); }
1205
+ else { idleCam('no-sdk'); }
1206
+ } else { idleCam('idle'); }
992
1207
  })
993
- .catch(function(){ if(note) note.textContent='no published cam track — showing preview (robot plugin pending)'; if(res) res.textContent='preview'; previewCam(n); });
1208
+ .catch(function(){ idleCam('unreachable'); });
1209
+ }
1210
+ function idleCam(reason){
1211
+ var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
1212
+ if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }
1213
+ var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
1214
+ var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
994
1215
  }
995
1216
  function attachLiveKit(info, n){
996
- var mount=$('pk-cam'), note=$('pk-camnote'), res=$('pk-camres'); if(!mount) return;
1217
+ var note=$('pk-camnote'), res=$('pk-camres');
997
1218
  try{
998
- var LK=window.LivekitClient, room=new LK.Room();
999
- room.connect(info.url, info.token).then(function(){
1000
- if(note) note.textContent='LiveKit · connected';
1001
- room.on(LK.RoomEvent.TrackSubscribed, function(track){
1002
- if(track.kind==='video'){ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true;
1003
- var cv=$('pk-camcv'); if(cv&&cv.parentNode){ cv.parentNode.replaceChild(el, cv); } if(res) res.textContent='LiveKit · live'; }
1004
- });
1005
- }).catch(function(){ if(note) note.textContent='LiveKit connect failed — preview'; previewCam(n); });
1006
- }catch(e){ if(note) note.textContent='LiveKit error — preview'; previewCam(n); }
1219
+ var LK=window.LivekitClient; lkRoom=new LK.Room();
1220
+ lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
1221
+ if(track.kind==='video'){
1222
+ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
1223
+ var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
1224
+ if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
1225
+ } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }
1226
+ });
1227
+ lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
1228
+ lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
1229
+ .catch(function(){ idleCam('unreachable'); });
1230
+ }catch(e){ idleCam('no-sdk'); }
1231
+ }
1232
+ // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
1233
+ // Audio analyser and read RMS in startMeters (no invented levels).
1234
+ function hookMicAnalyser(track){
1235
+ try{
1236
+ var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
1237
+ audioCtx=audioCtx||new AC();
1238
+ var ms=track.mediaStreamTrack; if(!ms) return;
1239
+ var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
1240
+ var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
1241
+ }catch(e){}
1007
1242
  }
1008
1243
  function previewCam(n){
1009
1244
  var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
@@ -1026,18 +1261,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
1026
1261
  function bars(el,n){ el.innerHTML=''; for(var i=0;i<n;i++){ var b=document.createElement('i'); el.appendChild(b); } return el.querySelectorAll('i'); }
1027
1262
  function startMeters(n){
1028
1263
  var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
1029
- var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw.getContext('2d'), s0=performance.now();
1030
- // Preview envelopes until the robot audio plugin streams real levels.
1264
+ var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);
1031
1265
  (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
1032
- var tt=(performance.now()-s0)/1000;
1033
- var env=Math.max(0,Math.sin(tt*2.3))*Math.max(0,Math.sin(tt*0.7));
1034
- for(var i=0;i<mi.length;i++){ var v=env*(0.4+0.6*Math.random())*(i<env*mi.length?1:0.25); mi[i].style.height=(3+v*13)+'px'; mi[i].style.background=i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'; }
1035
- set('pk-micval', Math.round(env*100)+'%'); set('pk-micstate', env>0.3?'preview · speech':'preview · quiet');
1036
- var spk=Math.max(0,Math.sin(tt*1.1-1)), speaking=spk>0.35;
1037
- for(var j=0;j<si.length;j++){ var vv=speaking?(0.3+0.7*Math.random()):0.05; si[j].style.height=(3+vv*13)+'px'; si[j].style.background=speaking?'var(--gold-bright)':'var(--glass-border)'; }
1038
- set('pk-spkval', speaking?Math.round(spk*100)+'%':'idle'); set('pk-spkstate', speaking?'preview · TTS':'preview · idle');
1039
- swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle=speaking?getVar('--gold-bright'):'rgba(255,255,255,0.12)'; swx.lineWidth=1.5; swx.beginPath();
1040
- for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.15+tt*8)*(speaking?spk*11:1.4)*Math.sin(x*0.03); if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke();
1266
+ // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
1267
+ var lvl=0;
1268
+ if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }
1269
+ for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }
1270
+ set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
1271
+ set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
1272
+ // SPEAKER the Pi plays TTS out its own speaker; it is not published as a
1273
+ // track, so report status honestly instead of inventing a level.
1274
+ for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }
1275
+ set('pk-spkval','—'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');
1276
+ if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke(); }
1041
1277
  meterRAF=requestAnimationFrame(loop);
1042
1278
  })();
1043
1279
  }