infinicode 2.8.30 → 2.8.32

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.
@@ -22,4 +22,4 @@
22
22
  * Kept free of backticks and ${...} so the whole page fits inside this TS
23
23
  * template literal without escaping; the client JS uses string concatenation.
24
24
  */
25
- export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n/* \u2500\u2500 premium tech glass theme \u2500\u2500 dark is the committed default; light is a frosted toggle \u2500\u2500 */\n:root{\n --bg:#08070F;\n --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),\n radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),\n radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),\n linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);\n --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;\n --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);\n --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);\n --ink:#F2ECDC; --muted:#9E93B4;\n --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);\n --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);\n --shadow: 0 10px 34px rgba(0,0,0,0.42);\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#EDEAF6;\n --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),\n radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),\n linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);\n --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;\n --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);\n --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);\n --ink:#241A08; --muted:#6E5A3E;\n --gold:#8A5A0C; --gold-bright:#B9781A;\n --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);\n --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);\n --shadow: 0 10px 30px rgba(60,40,10,0.14);\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;\n color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}\n:root[data-theme=\"light\"] .bar-track{background:rgba(90,70,20,0.14);}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}\n.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}\n.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.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;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}\n\n/* fleet card \u2014 live per-robot hardware */\n.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}\n.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}\n.statdot.off{background:var(--bad);}\n.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);}\n.node .mrow .ml{letter-spacing:0.04em;}\n.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}\n.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}\n.node .mrow .bar-track{margin:0;}\n.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}\n.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);}\n.node .stats span b{color:var(--ink);font-weight:600;}\n.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}\n.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);}\n.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}\n.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert b{color:var(--bad);}\n\n/* hardware meters */\n.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n border-top:1px solid var(--border-strong);padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;box-shadow:inset 0 1px 0 var(--glass-hi);}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);\n border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}\n:root[data-theme=\"light\"] footer input{background:rgba(255,255,255,0.6);}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:linear-gradient(180deg,var(--gold-bright),var(--gold));color:#231704;white-space:nowrap;box-shadow:0 4px 14px rgba(255,180,60,0.28);}\nbutton.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;\n background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);\n border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;\n border-bottom:1px solid var(--glass-border);}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:53px;z-index:19;}\n.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}\n.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.55rem;align-items:end;background:var(--panel-2);padding:0.7rem;border-radius:11px;border:1px solid var(--glass-border);margin:0.8rem 1.1rem;}\n.rp-form h3{grid-column:1/-1;font-family:var(--mono);font-size:0.7rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.2rem;}\n.rp-field{display:flex;flex-direction:column;gap:0.25rem;}\n.rp-field label{font-size:0.68rem;color:var(--muted);}\n.rp-field input,.rp-field select{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.38rem 0.5rem;}\n.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-card .rp-char{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.35rem 0.5rem;width:100%;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n\n/* \u2500\u2500 Park (isometric live map) \u2500\u2500 */\n#pane-park{padding:0;}\n.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}\n#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}\n#pk-canvas:active{cursor:grabbing;}\n.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;\n display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);\n padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}\n.pk-feed .rows{overflow-y:auto;padding:0.25rem;}\n.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}\n.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}\n.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}\n.pk-row .who{font-family:var(--mono);font-size:0.64rem;color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}\n.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}\n.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}\n.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}\n.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);\n background:var(--panel);border:1px solid var(--glass-border);border-radius:9px;padding:0.45rem 0.65rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);}\n.pk-hint b{color:var(--gold-bright);}\n.pk-legend{position:absolute;left:12px;top:12px;z-index:6;display:flex;gap:0.5rem;flex-wrap:wrap;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.pk-legend span{background:var(--panel);border:1px solid var(--glass-border);border-radius:100px;padding:0.2rem 0.55rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);display:inline-flex;gap:0.35rem;align-items:center;}\n.pk-legend i{width:8px;height:8px;border-radius:2px;}\n\n/* robot drawer */\n.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;\n background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);\n transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}\n.pk-drawer.open{transform:translateX(0);}\n.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}\n.pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));\n border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}\n.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}\n.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}\n.pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}\n.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.pk-pill{font-family:var(--mono);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.04em;padding:0.16em 0.5em;border-radius:100px;border:1px solid;}\n.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}\n.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}\n.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}\n.pk-sect{border-radius:12px;padding:0.75rem 0.8rem;background:var(--panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-hi);}\n.pk-sect h4{font-family:var(--mono);font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.55rem;display:flex;align-items:center;gap:0.4rem;}\n.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}\n.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}\n.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}\n.pk-cam .rec{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:0.35rem;color:#fff;text-shadow:0 1px 3px #000;}\n.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}\n.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}\n.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}\n.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}\n.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}\n.pk-val{font-family:var(--mono);font-size:0.66rem;color:var(--ink);width:52px;text-align:right;flex:none;font-variant-numeric:tabular-nums;}\n.pk-wave{height:32px;width:100%;}\n.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}\n.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}\n.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}\n.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}\n.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}\n.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}\n.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}\n.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}\n.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"park\" id=\"tabbtn-park\">Park</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions &amp; Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-park\">\n <div class=\"pk-stage\" id=\"pk-stage\">\n <canvas id=\"pk-canvas\"></canvas>\n <div class=\"pk-legend\">\n <span><i style=\"background:var(--gold-bright)\"></i>hub</span>\n <span><i style=\"background:var(--ok);border-radius:50%\"></i>robot</span>\n <span><i style=\"background:var(--info)\"></i>peer</span>\n <span><i style=\"background:var(--info)\"></i>dispatch</span>\n <span><i style=\"background:var(--ok)\"></i>telemetry</span>\n </div>\n <aside class=\"pk-feed\">\n <h3><span>Live activity</span><span style=\"color:var(--muted)\" id=\"pk-src\">/fed/stream</span></h3>\n <div class=\"rows\" id=\"pk-rows\"></div>\n </aside>\n <div class=\"pk-hint\">drag to pan \u00B7 scroll to zoom \u00B7 <b>click a robot</b> for cam / mic / speaker</div>\n </div>\n <div class=\"pk-drawer\" id=\"pk-drawer\">\n <div class=\"pk-dh\">\n <div class=\"pk-av\" id=\"pk-av\">\uD83E\uDD16</div>\n <div><div class=\"nm\" id=\"pk-name\">robot</div><div class=\"sub\" id=\"pk-sub\">satellite</div></div>\n <span class=\"pk-pill idle\" id=\"pk-state\" style=\"margin-left:0.4rem\">idle</span>\n <button class=\"x\" id=\"pk-close\">\u2715</button>\n </div>\n <div class=\"pk-dbody\" id=\"pk-body\"></div>\n </div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions &amp; telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n\n <!-- Add LiveKit server -->\n <div class=\"rp-form\" id=\"rp-addserver\">\n <h3>Add LiveKit server</h3>\n <div class=\"rp-field\"><label>ID</label><input id=\"srv-id\" placeholder=\"livekit-1-server\"></div>\n <div class=\"rp-field\"><label>Name</label><input id=\"srv-name\" placeholder=\"LiveKit Server 1\"></div>\n <div class=\"rp-field\"><label>URL (ws:// or wss://)</label><input id=\"srv-url\" placeholder=\"ws://livekit-1.tail2086c6.ts.net:7880\"></div>\n <div class=\"rp-field\"><label>API key</label><input id=\"srv-key\" placeholder=\"devkey\"></div>\n <div class=\"rp-field\"><label>API secret</label><input type=\"password\" id=\"srv-secret\" placeholder=\"secret\"></div>\n <div class=\"rp-field\"><label>Max sessions</label><input id=\"srv-max\" type=\"number\" value=\"8\"></div>\n <button class=\"rp-btn\" id=\"srv-add\" style=\"height:fit-content\">Add server</button>\n </div>\n\n <!-- Add robot / device -->\n <div class=\"rp-form\" id=\"rp-addrobot\">\n <h3>Add robot (mint enrollment token)</h3>\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rb-name\" placeholder=\"jaguar\"></div>\n <div class=\"rp-field\"><label>Character preset</label><select id=\"rb-char\"><option value=\"\">\u2014 auto \u2014</option></select></div>\n <div class=\"rp-field\"><label>Tailscale IP (optional)</label><input id=\"rb-tsip\" placeholder=\"100.x.x.x\"></div>\n <div class=\"rp-field\"><label>LAN IP (optional)</label><input id=\"rb-lanip\" placeholder=\"192.168.x.x\"></div>\n <div class=\"rp-field\"><label>Motor server URL (optional)</label><input id=\"rb-motor\" placeholder=\"http://...:5000\"></div>\n <div class=\"rp-field\"><label>LiveKit URL (optional)</label><input id=\"rb-lk\" placeholder=\"ws://...:7880\"></div>\n <div class=\"rp-field\"><label>Voice stack</label><select id=\"rb-voice\"><option value=\"\">\u2014 default \u2014</option></select></div>\n <button class=\"rp-btn\" id=\"rb-add\" style=\"height:fit-content\">Add robot</button>\n </div>\n\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n\n <!-- Config templates -->\n <div class=\"rp-form\" id=\"rp-templates\" style=\"grid-template-columns:1fr 1fr; margin-top:1.2rem;\">\n <h3 style=\"grid-column:1/-1\">Config templates</h3>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Voice stacks</h4>\n <div id=\"rp-voice-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newvoice-name\" placeholder=\"new stack name\" style=\"flex:1;min-width:0;\">\n <button class=\"rp-btn\" id=\"rp-addvoice\">Add</button>\n </div>\n </div>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Character presets</h4>\n <div id=\"rp-char-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newchar-name\" placeholder=\"new preset name\" style=\"flex:1;min-width:0;\">\n <input id=\"rp-newchar-stack\" list=\"rp-newchar-stack-list\" placeholder=\"voice stack id\" style=\"flex:1;min-width:0;\">\n <datalist id=\"rp-newchar-stack-list\"></datalist>\n <button class=\"rp-btn\" id=\"rp-addchar\">Add</button>\n </div>\n </div>\n </div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n <button class=\"act ghost\" id=\"updateBtn\" title=\"npm-update every node to the latest release and restart (supervised nodes come back on their own)\">Update fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastStatus = null; // most recent /fed/status snapshot (shared with Park)\n var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)\n var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)\n var FALLBACK_SLOTS = [\n {label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},\n {label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},\n {label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},\n {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},\n {label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}\n ];\n var parkSlots = FALLBACK_SLOTS.slice();\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme (dark glass is the default; toggle to frosted light)\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n document.documentElement.setAttribute('data-theme', s || 'dark');\n $('theme').addEventListener('click', function(){\n var cur=document.documentElement.getAttribute('data-theme')||'dark';\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e;\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n // \u2500\u2500 per-node hardware store + rolling history (drives the Fleet cards + sparklines) \u2500\u2500\n var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)\n var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers\n var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence\n function netTotal(hw){\n 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;\n }\n function netRxTx(hw){\n 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};\n }\n function pushHist(id, snap){\n var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});\n var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;\n var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;\n h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();\n h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();\n h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();\n }\n function updateHw(s){\n if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }\n var act = s.activity||[];\n 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; } }\n var nodes = s.nodes||[];\n for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }\n }\n // Inline sparkline (SVG polyline) from a numeric history buffer.\n function sparkSvg(arr, color, maxv){\n if(!arr || arr.length<2) return '';\n var w=100,h=20,n=arr.length, vals=[];\n for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);\n var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;\n 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); }\n return '<svg class=\"spark\" viewBox=\"0 0 '+w+' '+h+'\" preserveAspectRatio=\"none\" aria-hidden=\"true\">'\n + '<polyline points=\"'+pts+'\" fill=\"none\" stroke=\"'+color+'\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/></svg>';\n }\n function fmtRate(b){ return fmtBytes(b)+'/s'; }\n function fmtUptime(sec){ if(sec==null) return '\u2014'; 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'; }\n function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n 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'):''); }\n if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'\u00B0C'); }\n 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)); }\n var nt=netRxTx(hw); html += '<div class=\"stats\" style=\"margin-top:0.55rem\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span><span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function mrow(label, pct, valTxt, cls){\n var v = Math.max(0, Math.min(100, Math.round(pct||0)));\n return '<div class=\"mrow\"><span class=\"ml\">'+label+'</span>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div>'\n + '<span class=\"mv '+(cls||'')+'\">'+esc(valTxt)+'</span></div>';\n }\n function renderFleet(nodes){\n var el = $('fleet');\n 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; }\n // connected first, then robots/satellites above hubs so the fleet reads robot-first\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var accent = '#6FE0A6';\n try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var up = !!n.connected;\n var hw = hwByNode[n.nodeId] || null;\n var hist = hwHist[n.nodeId] || {};\n var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);\n var cores = hw&&hw.cpu ? hw.cpu.cores : null;\n var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;\n var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;\n var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;\n var nt = netRxTx(hw);\n var upt = hw&&hw.system ? hw.system.uptimeSec : null;\n var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n\n 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)+'\">'\n + '<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>'\n + '<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>';\n\n if(up){\n html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))\n + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')\n + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')\n + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'\u00B0C', tempClass(temp)) : '')\n + '<div class=\"stats\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span>'\n + '<span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')\n + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')\n + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'\n + '</div>';\n } else {\n html += '<div class=\"stats\"><span style=\"color:var(--muted)\">offline'+(n.lastSeenAt?(' \u00B7 last seen '+clock(n.lastSeenAt)):'')+'</span></div>';\n }\n html += (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'') + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n // live aggregates + alerts from per-node hardware\n var hotTemp=null, maxCpu=0, alerts=[];\n for(var k=0;k<nodes.length;k++){\n var nn=nodes[k]; if(!nn.connected) continue;\n var hw=hwByNode[nn.nodeId]; if(!hw) continue;\n if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);\n if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;\n if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'\u00B0C');\n if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');\n if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');\n 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+'%');\n }\n if(down>0) alerts.unshift(down+' offline');\n var html = '<span class=\"chip ok\">up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>'\n + '<span class=\"chip\">peak cpu <b>'+maxCpu+'%</b></span>'\n + (hotTemp!=null?('<span class=\"chip\">hottest <b>'+Math.round(hotTemp)+'\u00B0C</b></span>'):'');\n for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class=\"chip alert\">\u26A0 <b>'+esc(alerts[a2])+'</b></span>'; }\n $('summary').innerHTML = html;\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true); lastStatus = s;\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n updateHw(s);\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n Park.onStatus(s);\n }).catch(function(){ setLive(false); });\n }\n\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n $('updateBtn').addEventListener('click', function(){\n if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + Park + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-park').classList.toggle('active', name==='park');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var initHash = location.hash.replace('#','');\n // Defer tab init until Park module is defined (same outer IIFE, later in source).\n function initTab(){\n if (initHash==='robopark'||initHash==='park') selectTab(initHash);\n }\n // initTab() is called at the very bottom of this IIFE, after Park is assigned.\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null;\n if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n if(act==='trigger') triggerSession(id);\n if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }\n return;\n }\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n if(s.dataset.changed) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(!sid || cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n $('rp-grid').addEventListener('change', function(e){\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n\n var characterPresets=[];\n function loadCharacterPresets(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});\n }\n function loadVoiceStacks(){\n fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});\n }\n function loadParkSlots(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=Array.isArray(d)?d:(d&&d.presets)||[];\n var mapped=[];\n for(var i=0;i<list.length;i++){ var p=list[i];\n var id=p.id||p.preset_id||p.character_id||'';\n if(!id) continue;\n mapped.push({\n label:p.name||id.toUpperCase(), id:id,\n keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/s+/)).filter(function(x){return !!x;}),\n nx:(p.nx!=null?p.nx:0.5), ny:(p.ny!=null?p.ny:0.5),\n sub:p.sub||'', img:p.img||''\n });\n }\n parkSlots = mapped.length ? mapped : FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n }).catch(function(){\n parkSlots = FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n });\n }\n function fillCharacterDropdowns(){\n var sels=document.querySelectorAll('.rp-char');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<characterPresets.length;j++){ var p=characterPresets[j];\n var o=document.createElement('option'); o.value=p.id||p.preset_id||p.character_id||''; o.textContent=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):''); if((o.value)===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n function fillVoiceStackDropdowns(){\n var sels=document.querySelectorAll('.rp-voice');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<voiceStacks.length;j++){ var s=voiceStacks[j];\n var o=document.createElement('option'); o.value=s.id||''; o.textContent=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):''); if(o.value===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n\n function addServer(){\n var body={\n id:$('srv-id').value.trim(), name:$('srv-name').value.trim(), url:$('srv-url').value.trim(),\n api_key:$('srv-key').value.trim(), api_secret:$('srv-secret').value,\n max_sessions:parseInt($('srv-max').value,10)||8\n };\n if(!body.id||!body.name||!body.url){ showOut('Server ID, name, and URL are required'); return; }\n body.webhook_url=body.url;\n rpAction('POST','/robopark/api/servers',body).then(function(j){ showOut('server added: '+esc((j&&j.id)||body.id)); clearServerForm(); }).catch(function(e){ showOut('add server: '+e.message); });\n }\n function clearServerForm(){ $('srv-id').value=''; $('srv-name').value=''; $('srv-url').value=''; $('srv-key').value=''; $('srv-secret').value=''; $('srv-max').value='8'; }\n function addRobot(){\n var body={\n name:$('rb-name').value.trim(), character_id:$('rb-char').value||null,\n tailscale_ip:$('rb-tsip').value.trim()||null, lan_ip:$('rb-lanip').value.trim()||null,\n motor_server_url:$('rb-motor').value.trim()||null, livekit_url:$('rb-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n if(!body.name){ showOut('Robot name is required'); return; }\n rpAction('POST','/robopark/api/devices',body).then(function(j){ showOut('robot added: '+esc((j&&j.name)||body.name)+'\\ntoken: '+esc((j&&j.enrollment_token)||'(none)')); clearRobotForm(); pollRobopark(); }).catch(function(e){ showOut('add robot: '+e.message); });\n }\n function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; }\n function triggerSession(id){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });\n }\n function updateRobotCharacter(id, cid){\n if(!cid){ showOut('Choose a character preset'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(id)+'/voice-config',{character_id:cid}).then(function(){ showOut('updated '+id+' character to '+cid); pollRobopark(); }).catch(function(e){ showOut('voice-config ('+id+'): '+e.message); });\n }\n $('srv-add').addEventListener('click', addServer);\n $('rb-add').addEventListener('click', addRobot);\n $('rp-addvoice').addEventListener('click', function(){ addVoiceStackFromTemplate(); });\n $('rp-addchar').addEventListener('click', function(){ addCharacterFromTemplate(); });\n\n function addVoiceStackFromTemplate(){\n var name=$('rp-newvoice-name').value.trim(); if(!name){ showOut('Voice stack name required'); return; }\n rpAction('POST','/robopark/api/voice-stacks',{name:name}).then(function(j){ showOut('voice stack added: '+esc(j.id||name)); $('rp-newvoice-name').value=''; loadVoiceStacks(); }).catch(function(e){ showOut('add voice stack: '+e.message); });\n }\n function addCharacterFromTemplate(){\n var name=$('rp-newchar-name').value.trim(); if(!name){ showOut('Preset name required'); return; }\n var voice_stack_id=$('rp-newchar-stack').value.trim()||null;\n var id=name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,32);\n rpAction('POST','/robopark/api/character-presets',{id:id,name:name,voice_stack_id:voice_stack_id,motors:['drive']}).then(function(j){ showOut('character preset added: '+esc(j.id||id)); $('rp-newchar-name').value=''; $('rp-newchar-stack').value=''; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('add character preset: '+e.message); });\n }\n function fillTemplateVoiceList(){\n var el=$('rp-voice-list'); if(!el) return;\n var html='';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class=\"rp-card\" style=\"padding:0.5rem 0.6rem;border-left-color:var(--info)\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(s.name||s.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">'+esc(s.stt_provider)+' \u2192 '+esc(s.llm_provider)+' \u2192 '+esc(s.tts_provider)+'</div></div>'; }\n if(!voiceStacks.length) html='<div class=\"pk-note\">no voice stacks loaded</div>';\n el.innerHTML=html;\n var dl=$('rp-newchar-stack-list'); if(dl){ dl.innerHTML=''; for(var j=0;j<voiceStacks.length;j++){ var o=document.createElement('option'); o.value=voiceStacks[j].id; dl.appendChild(o); } }\n }\n function fillTemplateCharacterList(){\n var el=$('rp-char-list'); if(!el) return;\n var html='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class=\"rp-card\" style=\"padding:0.5rem 0.6rem;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(p.name||p.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">stack: '+esc(p.voice_stack_id||'\u2014')+' \u00B7 motors: '+esc((p.motors||[]).join(', '))+'</div></div>'; }\n if(!characterPresets.length) html='<div class=\"pk-note\">no character presets loaded</div>';\n el.innerHTML=html;\n }\n\n function renderRobotCardSelect(name, current){\n var sel=$('rp-char-'+name); if(!sel) return;\n sel.dataset.current=current||'';\n fillCharacterDropdowns();\n }\n\n function pollRobopark(){\n pollSettings();\n loadCharacterPresets();\n loadVoiceStacks();\n loadParkSlots();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n // stash per-robot rows so the Park drawer can show live session stats\n roboparkByName = {};\n robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\" data-name=\"'+esc(name)+'\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn\" data-act=\"trigger\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Trigger motion</button>'\n + '<button class=\"rp-btn\" data-act=\"preview\" data-robot=\"'+idAttr+'\">Preview</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div>'\n + '<div class=\"rp-field\" style=\"margin-top:0.45rem\"><label style=\"font-size:0.64rem\">Character preset</label><select class=\"rp-char\" data-act=\"char\" data-robot=\"'+idAttr+'\"><option value=\"\">'+esc(x.character_id||x.character_preset_id||'\u2014 auto \u2014')+'</option></select></div></div>';\n }\n grid.innerHTML = html;\n fillCharacterDropdowns();\n var sels=document.querySelectorAll('.rp-char');\n for(var si=0;si<sels.length;si++){ var ss=sels[si]; var current=ss.value; ss.dataset.current=current; }\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n // Park \u2014 isometric live map of the mesh\n // Real data: /fed/status nodes (placement + load) + /fed/stream frames\n // (agent movement + tool-use pulses) + /robopark telemetry (session stats).\n // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n var Park = (function(){\n var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial photo): named robot pads at their ground\n // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,\n // street and stage. A mesh node binds to a pad when its display name matches\n // the pad keywords (robobmw \u2192 VIXEN/BMW).\n // SLOTS is now rebuilt from scheduler character_presets; keep the same shape.\n var SLOTS = parkSlots.slice();\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};\n // Palm positions tuned to the aerial: clusters along the back edge and\n // scattered down the center of the paved strip.\n var PALMS=[\n [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],\n [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],\n [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],\n [0.62,0.80],[0.76,0.84],[0.35,0.82]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};\n // Street traffic along the top road (yellow taxis + private cars).\n var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\n function kindOf(n){\n var r=(n.role||'').toLowerCase();\n if(r==='hub'||r==='scheduler') return 'hub';\n if(r==='satellite'||r==='robot') return 'robot';\n var caps=(n.capabilities||[]).join(' ');\n if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';\n return 'peer';\n }\n function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }\n function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }\n\n // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n 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; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\n });\n return out;\n }\n\n function onStatus(s){\n if(!s) return;\n nodes = layout(s.nodes||[]);\n byId={}; nodes.forEach(function(n){ byId[n.id]=n; });\n // if not streaming, animate from the polled activity tail\n if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }\n }\n\n function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }\n function findByFrame(f){\n var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\n return null;\n }\n function ingest(f, fromPoll){\n if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }\n var n=findByFrame(f), h=hub();\n if(n && h){\n if(n.kind==='hub'){ pulse(n, glyphFor(f)); }\n else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }\n else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }\n }\n feedRow(f);\n }\n function glyphFor(f){\n if(f.t==='rt') return '\u21C4';\n if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '\u2328'; if(/session|presence/.test(k)) return '\uD83C\uDF99'; return '\u2726'; }\n if(f.t==='hw'||f.t==='hb') return '';\n return '\u2726';\n }\n function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }\n function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'\u2726'}); }\n\n function feedRow(f){\n var rows=$('pk-rows'); if(!rows) return;\n var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;\n var r=document.createElement('div'); r.className='pk-row';\n r.innerHTML='<span class=\"k pk-k-'+esc(kind)+'\"></span><span class=\"t\">'+clock(f.ts)+'</span>'\n +'<span class=\"who\" title=\"'+esc(f.n)+'\">'+esc((f.n||'').slice(0,10))+'</span>'\n +'<span class=\"msg\">'+esc(summarizeFrame(f))+'</span>';\n rows.insertBefore(r, rows.firstChild);\n while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);\n }\n\n // \u2500\u2500 canvas + projection \u2500\u2500\n function ensure(){\n if(C) return;\n C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();\n DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));\n var d=false,px,py;\n C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});\n window.addEventListener('mouseup',function(){d=false;});\n window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });\n C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});\n C.addEventListener('click',function(e){\n var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;\n 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]; }\n if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }\n });\n window.addEventListener('resize', function(){ if(running) resize(); });\n }\n function resize(){\n var st=$('pk-stage'); if(!st) return;\n W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);\n if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n 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); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n 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)');\n 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);\n // street band along the far edge + dashed centerline + cars\n 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);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n 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([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // white event tent / stage on the far left of the plaza (matches aerial)\n drawTent(0.85, 0.18);\n // paved plaza \u2014 long rectangular promenade matching the aerial footprint\n var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;\n 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)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n // plaza grid (tile joints)\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ 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(); }\n for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ 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(); }\n // curved pond / water feature at bottom-left\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }\n 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)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n // winding footpath along the pond\n X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);\n var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];\n X.beginPath();\n for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }\n X.stroke();\n }\n function drawTent(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // canopy shadow\n X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();\n // white peaked tent\n X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n // support poles\n X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;\n X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();\n X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n 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();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n 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();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n 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();\n 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();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n 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(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n 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();\n X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();\n }\n function label(cx,cy,txt,col,sub){\n var z=cam.z; X.textAlign='center';\n X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);\n X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);\n }\n 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(); }\n function drawHub(n){\n 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;\n 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();\n X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;\n X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();\n 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; }\n X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();\n X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();\n label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};\n }\n function drawRobot(n){\n 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;\n 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);\n 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();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n var img = n.connected && imgs[n.label];\n if(img && img.complete && img.naturalWidth>0){\n var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();\n X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);\n X.restore();\n X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n 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();\n 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();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n }\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n 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();\n var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';\n X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;\n X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();\n 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;\n label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));\n n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};\n }\n\n function frame(){\n if(!running) return;\n X.clearRect(0,0,W,H);\n drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n 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}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n 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); } });\n 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;}\n 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;\n 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;\n 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;\n }\n 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;}\n var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;\n 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);\n 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;\n }\n raf=requestAnimationFrame(frame);\n }\n\n function connectStream(){\n if(es) return;\n try{\n es=new EventSource('/fed/stream'+QS);\n es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };\n es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream \u00B7 live'; };\n es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status \u00B7 poll'; };\n }catch(e){ es=null; }\n }\n\n var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; };\n // The SDK is ~500KB \u2014 a camera view or \"Go live\" click can easily fire\n // before it finishes loading. Without this, that first attempt sees\n // window.LivekitClient===undefined, falls into the permanent \"no-sdk\"\n // idle state, and never retries even once the SDK is ready.\n s.onload=function(){\n sdkLoading=false;\n if(!drawerId) return;\n var n=byId[drawerId];\n if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }\n };\n document.head.appendChild(s);\n }\n function loadImages(){\n SLOTS.forEach(function(s){ if(!s.img) return; var i=new Image(); i.src=s.img+QS; i.onload=function(){ imgs[s.label]=i; }; i.onerror=function(){ imgs[s.label]=null; }; });\n }\n function updateSlots(newSlots){\n SLOTS = newSlots.slice();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n }\n function activate(){\n ensure();\n loadSDK();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n resize();\n connectStream();\n if(!running){ running=true; raf=requestAnimationFrame(frame); }\n }\n function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }\n\n // \u2500\u2500 robot drawer \u2500\u2500\n function sect(title, inner){ return '<div class=\"pk-sect\"><h4>'+title+'</h4>'+inner+'</div>'; }\n function openDrawer(id){\n var n=byId[id]; if(!n) return; drawerId=id;\n $('pk-drawer').classList.add('open');\n $('pk-av').textContent = n.kind==='hub'?'\uD83D\uDDA5':(n.kind==='robot'?'\uD83E\uDD16':'\uD83E\uDDE9');\n $('pk-name').textContent=n.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');\n buildDrawer(n);\n pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick\n if(drawerTimer) clearInterval(drawerTimer);\n drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);\n }\n // Resolve a mesh node's *scheduler* robot id \u2014 n.name is the mesh display\n // name, which is not necessarily the scheduler's robots.id (e.g. an\n // enrolled device's id is a generated dev_xxxx, distinct from its name).\n // Falls back to n.name if telemetry hasn't matched a row yet.\n function schedRow(n){\n var row=roboparkByName[n.name];\n 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; } }\n return row;\n }\n function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }\n function closeDrawer(){\n // tell the scheduler to end any live preview so the robot stops its cam\n if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }\n previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n stopVision();\n $('pk-drawer').classList.remove('open'); drawerId=null;\n if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;\n if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;\n if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }\n if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=null;\n }\n\n // \u2500\u2500 setup flow for unjoined park pads \u2500\u2500\n function submitPadSetup(n){\n var name=$('pk-setup-name').value.trim() || (n.label||n.id||'robot').toString().toLowerCase();\n var body={\n name:name,\n character_id:$('pk-setup-char').value||null,\n tailscale_ip:$('pk-setup-tsip').value.trim()||null,\n lan_ip:$('pk-setup-lanip').value.trim()||null,\n motor_server_url:$('pk-setup-motor').value.trim()||null,\n livekit_url:$('pk-setup-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n rpAction('POST','/robopark/api/devices',body).then(function(j){\n var token=(j&&j.enrollment_token)||'';\n $('pk-setup-token').value=token;\n $('pk-setup-result').style.display='block';\n showOut('robot added: '+esc((j&&j.name)||name)+'\ntoken: '+esc(token));\n pollRoboparkQuiet();\n }).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });\n }\n\n // \u2500\u2500 effective voice config for connected robots \u2500\u2500\n function loadEffectiveConfig(robotId){\n fetch('/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(cfg){\n var note=$('pk-eff-note'); if(note) note.textContent='Effective config: '+(cfg&&cfg.character_name?esc(cfg.character_name):'\u2014')+' / '+(cfg&&cfg.voice_stack&&cfg.voice_stack.name?esc(cfg.voice_stack.name):'\u2014');\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n if(csel){ csel.value=(cfg&&cfg.character_id)||''; csel.dataset.current=csel.value; }\n if(vsel){ vsel.value=(cfg&&cfg.voice_stack_id)||''; vsel.dataset.current=vsel.value; }\n var saveBtn=$('pk-eff-save');\n if(saveBtn && !saveBtn._bound){ saveBtn._bound=true; saveBtn.addEventListener('click', function(){ applyEffectiveConfig(robotId); }); }\n }).catch(function(){\n var note=$('pk-eff-note'); if(note) note.textContent='effective config unavailable';\n });\n }\n function applyEffectiveConfig(robotId){\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n var body={};\n if(csel && csel.value) body.character_preset_id=csel.value;\n if(vsel && vsel.value) body.voice_stack_id=vsel.value;\n if(!body.character_preset_id && !body.voice_stack_id){ showOut('Choose a character preset or voice stack'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config',body).then(function(cfg){\n showOut('updated robot config: '+(cfg&&cfg.character_name||robotId));\n if(csel) csel.dataset.current=csel.value;\n if(vsel) vsel.dataset.current=vsel.value;\n }).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });\n }\n\n // \u2500\u2500 on-demand operator preview: open the robot's cam without a scene-detection session \u2500\u2500\n function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'\u25A0 Stop live':'\u25B6 Go live'; b.classList.toggle('danger', previewOn); } }\n function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n var note=$('pk-golivenote'); if(note) note.textContent='requesting preview\u2026';\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first\n idleCam('connecting');\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(!previewOn) return; // stopped while awaiting\n if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }\n if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview \u00B7 robot opening camera\u2026'; }\n else { idleCam('no-sdk'); }\n if(previewTimer) clearInterval(previewTimer);\n previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);\n })\n .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });\n }\n function stopPreview(n){\n previewOn=false; updateGoLive();\n if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});\n var note=$('pk-golivenote'); if(note) note.textContent='';\n idleCam('idle');\n }\n // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),\n // so the browser hits the robot's LAN IP straight on, no scheduler proxy.\n // The scheduler learns that IP from the device's own heartbeats.\n function toggleVision(n){\n var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');\n if(visionOn){\n visionOn=false; if(img){ img.src=''; img.style.display='none'; }\n if(btn) btn.textContent='\uD83D\uDD0D Show overlay';\n if(note) note.textContent=\"object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)\";\n return;\n }\n var row=schedRow(n);\n var ip=row&&row.lan_ip;\n if(!ip){ if(note) note.textContent='no known IP for this robot yet \u2014 waiting for a heartbeat with lan_ip'; return; }\n var port=(row&&row.vision_port)||5000;\n visionOn=true;\n if(btn) btn.textContent='\u25A0 Hide overlay';\n if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';\n if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }\n }\n function stopVision(){\n visionOn=false;\n var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }\n }\n function pollRoboparkQuiet(){\n fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};\n list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function buildDrawer(n){\n var b=$('pk-body');\n if(n.kind==='robot' && !n.connected){\n var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/s+/g,'');\n var presetOptions='', voiceOptions='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; presetOptions += '<option value=\"'+esc(p.id||'')+'\"'+((p.id===n.id)?' selected':'')+'>'+esc(p.name||p.id||'')+'</option>'; }\n for(var v=0;v<voiceStacks.length;v++){ var s=voiceStacks[v]; voiceOptions += '<option value=\"'+esc(s.id||'')+'\">'+esc(s.name||s.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label),\n '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted);margin-bottom:0.55rem;\">Set up this pad before a robot joins it.</div>'\n + '<div class=\"pk-grid2\" style=\"margin-bottom:0.55rem;\">'\n + '<div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div>'\n + '<div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div>'\n + '</div>'\n + '<div class=\"rp-form\" style=\"padding:0.55rem;grid-template-columns:1fr;gap:0.5rem;margin:0;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Robot name</label><input id=\"pk-setup-name\" value=\"'+esc(defaultName)+'\"></div>'\n + '<div class=\"rp-field\"><label>Character preset</label><select id=\"pk-setup-char\"><option value=\"\">\u2014 auto \u2014</option>'+presetOptions+'</select></div>'\n + '<div class=\"rp-field\"><label>Tailscale IP</label><input id=\"pk-setup-tsip\" placeholder=\"100.x.x.x\"></div>'\n + '<div class=\"rp-field\"><label>LAN IP</label><input id=\"pk-setup-lanip\" placeholder=\"192.168.x.x\"></div>'\n + '<div class=\"rp-field\"><label>Motor server URL</label><input id=\"pk-setup-motor\" placeholder=\"http://...:5000\"></div>'\n + '<div class=\"rp-field\"><label>LiveKit URL</label><input id=\"pk-setup-lk\" placeholder=\"ws://...:7880\"></div>'\n + '<div class=\"rp-field\"><label>Voice stack</label><select id=\"pk-setup-voice\"><option value=\"\">\u2014 default \u2014</option>'+voiceOptions+'</select></div>'\n + '<button class=\"rp-btn\" id=\"pk-setup-btn\" data-preset=\"'+esc(n.id)+'\" style=\"width:fit-content;\">Mint enrollment token & add robot</button>'\n + '</div>'\n + '<div class=\"pk-sect\" id=\"pk-setup-result\" style=\"display:none;\"><h4>Enrollment token (copy once)</h4><input id=\"pk-setup-token\" class=\"rp-char\" readonly style=\"margin-bottom:0.4rem;\"><button class=\"rp-btn\" id=\"pk-setup-copy\">Copy</button></div>');\n var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });\n 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'); } });\n return;\n }\n if(n.kind==='robot'){\n var effPresetOptions='', effVoiceOptions='';\n for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value=\"'+esc(cp.id||'')+'\"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }\n for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value=\"'+esc(vs.id||'')+'\">'+esc(vs.name||vs.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83D\uDC41 camera feed',\n '<div class=\"pk-cam\" id=\"pk-cam\"><canvas id=\"pk-camcv\" width=\"384\" height=\"216\"></canvas>'\n +'<div class=\"hud\"><div class=\"rec\"><i></i>REC \u00B7 '+esc(n.name)+'</div><div class=\"tr\" id=\"pk-camres\">\u2014 \u00B7 \u2014</div><div class=\"br\" id=\"pk-camts\">--:--:--</div></div></div>'\n +'<div class=\"pk-note\" id=\"pk-camnote\">seeking LiveKit track\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u25B6 Go live</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\"></span></div>')\n + sect('\uD83D\uDD0D vision overlay',\n '<div class=\"pk-cam\" id=\"pk-vision-wrap\"><img id=\"pk-vision-img\" style=\"width:100%;display:none;border-radius:inherit\" alt=\"vision feed\"/></div>'\n +'<div class=\"pk-note\" id=\"pk-vision-note\">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-vision-toggle\">\uD83D\uDD0D Show overlay</button></div>')\n + sect('\uD83C\uDF99 microphone','<div class=\"pk-meter\"><span class=\"lab\">input</span><div class=\"pk-bars\" id=\"pk-micbars\"></div><span class=\"pk-val\" id=\"pk-micval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-micstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDD0A speaker','<canvas class=\"pk-wave\" id=\"pk-spkwave\" width=\"360\" height=\"32\"></canvas><div class=\"pk-meter\"><span class=\"lab\">output</span><div class=\"pk-bars\" id=\"pk-spkbars\"></div><span class=\"pk-val\" id=\"pk-spkval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-spkstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDCCA hardware','<div class=\"pk-hwgrid\"><div><div class=\"l\">load</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-load\"></div></div></div><div><div class=\"l\">status</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-conn\" style=\"background:'+(n.connected?'var(--ok)':'var(--muted)')+'\"></div></div></div></div><div class=\"pk-note\">remote node \u2014 full CPU/mem telemetry shows on the node itself</div>')\n + sect('\uD83D\uDDD3 session telemetry','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-trig\">\u2014</div><div class=\"l\">triggers</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-sess\">\u2014</div><div class=\"l\">sessions</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-lat\">\u2014</div><div class=\"l\">avg latency</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-drop\">\u2014</div><div class=\"l\">drop rate</div></div></div>')\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn danger\" id=\"pk-end\" data-robot=\"'+esc(n.name)+'\">End session</button><button class=\"rp-btn\" id=\"pk-follow\">Follow run</button></div><div class=\"pk-note\">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>')\n + sect('\uD83C\uDFAD character / voice','<div class=\"pk-note\" id=\"pk-eff-note\" style=\"margin-bottom:0.45rem;\">loading effective config\u2026</div><div class=\"rp-field\"><label>Character preset</label><select class=\"rp-char\" id=\"pk-eff-char\" data-robot=\"'+esc(schedId(n))+'\">'+effPresetOptions+'</select></div><div class=\"rp-field\"><label>Voice stack</label><select class=\"rp-voice\" id=\"pk-eff-voice\" data-robot=\"'+esc(schedId(n))+'\">'+effVoiceOptions+'</select></div><button class=\"rp-btn\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\" style=\"margin-top:0.35rem;\">Set up on this robot</button>');\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n));\n var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });\n } else {\n b.innerHTML =\n sect('\uD83D\uDDA5 node','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-load\">\u2014</div><div class=\"l\">load</div></div><div class=\"pk-kv\"><div class=\"v '+(n.connected?'ok':'')+'\">'+(n.connected?'up':'down')+'</div><div class=\"l\">status</div></div></div>')\n + sect('\uD83D\uDD0C capabilities', (n.caps.length?('<div class=\"caps\">'+n.caps.map(function(c){return '<span class=\"cap\">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class=\"pk-note\">none reported</div>'))\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-status\">Run /status</button></div>');\n var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });\n }\n refreshDrawer();\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);\n var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';\n var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';\n var row=schedRow(n);\n if(row){\n set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);\n set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);\n set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'\u2014');\n 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+'%':'\u2014'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }\n var active=row.active_session||row.in_session||row.session_active;\n var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }\n }\n }\n function set(id,v){ var e=$(id); if(e) e.textContent=v; }\n function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n var note=$('pk-camnote');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n 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); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\n }\n function previewCam(n){\n var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();\n var res=$('pk-camres'); if(res && res.textContent==='\u2014 \u00B7 \u2014') res.textContent='preview \u00B7 24fps';\n (function loop(){ if(!$('pk-camcv')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n var g=cx.createLinearGradient(0,0,0,h); g.addColorStop(0,'#0a1420'); g.addColorStop(1,'#132233'); cx.fillStyle=g; cx.fillRect(0,0,w,h);\n cx.fillStyle='#0e1a12'; cx.beginPath(); cx.moveTo(0,h*0.62); cx.lineTo(w,h*0.62); cx.lineTo(w,h); cx.lineTo(0,h); cx.fill();\n var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;\n cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();\n cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);\n cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);\n cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);\n cx.globalAlpha=0.06; for(var y=0;y<h;y+=3){ cx.fillStyle=y%2?'#fff':'#000'; cx.fillRect(0,y,w,1); } cx.globalAlpha=1;\n for(var k=0;k<30;k++){ cx.fillStyle='rgba(255,255,255,'+(Math.random()*0.05)+')'; cx.fillRect(Math.random()*w,Math.random()*h,1,1); }\n var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());\n camRAF=requestAnimationFrame(loop);\n })();\n }\n 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'); }\n function startMeters(n){\n var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;\n var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n 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); }\n 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)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n 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(); }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n function openDrawerForRobot(name){\n var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i]; if(n.kind==='robot' && ((n.name||'').toLowerCase()===key || n.telemKeys.some(function(t){ return t===key; }))){ found=n; break; } }\n var node=found || { id:id, kind:'robot', name:name, telemKeys:[key], connected:true, label:name };\n if(!byId[id]){ byId[id]=node; nodes.push(node); }\n openDrawer(node.id);\n }\n\n $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);\n return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n initTab();\n})();\n</script>\n</body>\n</html>";
25
+ export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n/* \u2500\u2500 premium tech glass theme \u2500\u2500 dark is the committed default; light is a frosted toggle \u2500\u2500 */\n:root{\n --bg:#08070F;\n --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),\n radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),\n radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),\n linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);\n --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;\n --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);\n --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);\n --ink:#F2ECDC; --muted:#9E93B4;\n --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);\n --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);\n --shadow: 0 10px 34px rgba(0,0,0,0.42);\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#EDEAF6;\n --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),\n radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),\n linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);\n --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;\n --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);\n --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);\n --ink:#241A08; --muted:#6E5A3E;\n --gold:#8A5A0C; --gold-bright:#B9781A;\n --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);\n --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);\n --shadow: 0 10px 30px rgba(60,40,10,0.14);\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;\n color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}\n:root[data-theme=\"light\"] .bar-track{background:rgba(90,70,20,0.14);}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}\n.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}\n.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.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;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}\n\n/* fleet card \u2014 live per-robot hardware */\n.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}\n.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}\n.statdot.off{background:var(--bad);}\n.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);}\n.node .mrow .ml{letter-spacing:0.04em;}\n.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}\n.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}\n.node .mrow .bar-track{margin:0;}\n.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}\n.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);}\n.node .stats span b{color:var(--ink);font-weight:600;}\n.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}\n.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);}\n.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}\n.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert b{color:var(--bad);}\n\n/* hardware meters */\n.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n border-top:1px solid var(--border-strong);padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;box-shadow:inset 0 1px 0 var(--glass-hi);}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);\n border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}\n:root[data-theme=\"light\"] footer input{background:rgba(255,255,255,0.6);}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:linear-gradient(180deg,var(--gold-bright),var(--gold));color:#231704;white-space:nowrap;box-shadow:0 4px 14px rgba(255,180,60,0.28);}\nbutton.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;\n background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);\n border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;\n border-bottom:1px solid var(--glass-border);}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:53px;z-index:19;}\n.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}\n.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.55rem;align-items:end;background:var(--panel-2);padding:0.7rem;border-radius:11px;border:1px solid var(--glass-border);margin:0.8rem 1.1rem;}\n.rp-form h3{grid-column:1/-1;font-family:var(--mono);font-size:0.7rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.2rem;}\n.rp-field{display:flex;flex-direction:column;gap:0.25rem;}\n.rp-field label{font-size:0.68rem;color:var(--muted);}\n.rp-field input,.rp-field select{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.38rem 0.5rem;}\n.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-card .rp-char{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.35rem 0.5rem;width:100%;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n\n/* \u2500\u2500 Park (isometric live map) \u2500\u2500 */\n#pane-park{padding:0;}\n.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}\n#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}\n#pk-canvas:active{cursor:grabbing;}\n.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;\n display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);\n padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}\n.pk-feed .rows{overflow-y:auto;padding:0.25rem;}\n.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}\n.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}\n.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}\n.pk-row .who{font-family:var(--mono);font-size:0.64rem;color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}\n.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}\n.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}\n.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}\n.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);\n background:var(--panel);border:1px solid var(--glass-border);border-radius:9px;padding:0.45rem 0.65rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);}\n.pk-hint b{color:var(--gold-bright);}\n.pk-legend{position:absolute;left:12px;top:12px;z-index:6;display:flex;gap:0.5rem;flex-wrap:wrap;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.pk-legend span{background:var(--panel);border:1px solid var(--glass-border);border-radius:100px;padding:0.2rem 0.55rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);display:inline-flex;gap:0.35rem;align-items:center;}\n.pk-legend i{width:8px;height:8px;border-radius:2px;}\n\n/* robot drawer */\n.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;\n background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);\n transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}\n.pk-drawer.open{transform:translateX(0);}\n.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}\n.pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));\n border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}\n.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}\n.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}\n.pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}\n.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.pk-pill{font-family:var(--mono);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.04em;padding:0.16em 0.5em;border-radius:100px;border:1px solid;}\n.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}\n.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}\n.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}\n.pk-sect{border-radius:12px;padding:0.75rem 0.8rem;background:var(--panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-hi);}\n.pk-sect h4{font-family:var(--mono);font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.55rem;display:flex;align-items:center;gap:0.4rem;}\n.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}\n.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}\n.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}\n.pk-cam .rec{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:0.35rem;color:#fff;text-shadow:0 1px 3px #000;}\n.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}\n.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}\n.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}\n.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}\n.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}\n.pk-val{font-family:var(--mono);font-size:0.66rem;color:var(--ink);width:52px;text-align:right;flex:none;font-variant-numeric:tabular-nums;}\n.pk-wave{height:32px;width:100%;}\n.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}\n.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}\n.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}\n.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}\n.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}\n.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}\n.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}\n.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}\n.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"park\" id=\"tabbtn-park\">Park</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions &amp; Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-park\">\n <div class=\"pk-stage\" id=\"pk-stage\">\n <canvas id=\"pk-canvas\"></canvas>\n <div class=\"pk-legend\">\n <span><i style=\"background:var(--gold-bright)\"></i>hub</span>\n <span><i style=\"background:var(--ok);border-radius:50%\"></i>robot</span>\n <span><i style=\"background:var(--info)\"></i>peer</span>\n <span><i style=\"background:var(--info)\"></i>dispatch</span>\n <span><i style=\"background:var(--ok)\"></i>telemetry</span>\n </div>\n <aside class=\"pk-feed\">\n <h3><span>Live activity</span><span style=\"color:var(--muted)\" id=\"pk-src\">/fed/stream</span></h3>\n <div class=\"rows\" id=\"pk-rows\"></div>\n </aside>\n <div class=\"pk-hint\">drag to pan \u00B7 scroll to zoom \u00B7 <b>click a robot</b> for cam / mic / speaker</div>\n </div>\n <div class=\"pk-drawer\" id=\"pk-drawer\">\n <div class=\"pk-dh\">\n <div class=\"pk-av\" id=\"pk-av\">\uD83E\uDD16</div>\n <div><div class=\"nm\" id=\"pk-name\">robot</div><div class=\"sub\" id=\"pk-sub\">satellite</div></div>\n <span class=\"pk-pill idle\" id=\"pk-state\" style=\"margin-left:0.4rem\">idle</span>\n <button class=\"x\" id=\"pk-close\">\u2715</button>\n </div>\n <div class=\"pk-dbody\" id=\"pk-body\"></div>\n </div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions &amp; telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n\n <!-- Add LiveKit server -->\n <div class=\"rp-form\" id=\"rp-addserver\">\n <h3>Add LiveKit server</h3>\n <div class=\"rp-field\"><label>ID</label><input id=\"srv-id\" placeholder=\"livekit-1-server\"></div>\n <div class=\"rp-field\"><label>Name</label><input id=\"srv-name\" placeholder=\"LiveKit Server 1\"></div>\n <div class=\"rp-field\"><label>URL (ws:// or wss://)</label><input id=\"srv-url\" placeholder=\"ws://livekit-1.tail2086c6.ts.net:7880\"></div>\n <div class=\"rp-field\"><label>API key</label><input id=\"srv-key\" placeholder=\"devkey\"></div>\n <div class=\"rp-field\"><label>API secret</label><input type=\"password\" id=\"srv-secret\" placeholder=\"secret\"></div>\n <div class=\"rp-field\"><label>Max sessions</label><input id=\"srv-max\" type=\"number\" value=\"8\"></div>\n <button class=\"rp-btn\" id=\"srv-add\" style=\"height:fit-content\">Add server</button>\n </div>\n\n <!-- Add robot / device -->\n <div class=\"rp-form\" id=\"rp-addrobot\">\n <h3>Add robot (mint enrollment token)</h3>\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rb-name\" placeholder=\"jaguar\"></div>\n <div class=\"rp-field\"><label>Character preset</label><select id=\"rb-char\"><option value=\"\">\u2014 auto \u2014</option></select></div>\n <div class=\"rp-field\"><label>Tailscale IP (optional)</label><input id=\"rb-tsip\" placeholder=\"100.x.x.x\"></div>\n <div class=\"rp-field\"><label>LAN IP (optional)</label><input id=\"rb-lanip\" placeholder=\"192.168.x.x\"></div>\n <div class=\"rp-field\"><label>Motor server URL (optional)</label><input id=\"rb-motor\" placeholder=\"http://...:5000\"></div>\n <div class=\"rp-field\"><label>LiveKit URL (optional)</label><input id=\"rb-lk\" placeholder=\"ws://...:7880\"></div>\n <div class=\"rp-field\"><label>Voice stack</label><select id=\"rb-voice\"><option value=\"\">\u2014 default \u2014</option></select></div>\n <button class=\"rp-btn\" id=\"rb-add\" style=\"height:fit-content\">Add robot</button>\n </div>\n\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n\n <!-- Config templates -->\n <div class=\"rp-form\" id=\"rp-templates\" style=\"grid-template-columns:1fr 1fr; margin-top:1.2rem;\">\n <h3 style=\"grid-column:1/-1\">Config templates</h3>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Voice stacks</h4>\n <div id=\"rp-voice-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newvoice-name\" placeholder=\"new stack name\" style=\"flex:1;min-width:0;\">\n <button class=\"rp-btn\" id=\"rp-addvoice\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-voice-edit\" style=\"display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"grid-column:1/-1;margin:0 0 0.2rem;\">Edit voice stack: <span id=\"rpve-name-label\"></span></h4>\n <div class=\"rp-field\"><label>STT provider</label><select id=\"rpve-stt-provider\"><option value=\"speaches\">speaches</option><option value=\"groq\">groq</option><option value=\"vibevoice\">vibevoice</option></select></div>\n <div class=\"rp-field\"><label>STT model</label><input id=\"rpve-stt-model\"></div>\n <div class=\"rp-field\"><label>STT language</label><input id=\"rpve-stt-language\" placeholder=\"en / auto\"></div>\n <div class=\"rp-field\"><label>LLM provider</label><select id=\"rpve-llm-provider\"><option value=\"ollama\">ollama</option><option value=\"groq\">groq</option><option value=\"gemini\">gemini</option><option value=\"moltbot\">moltbot</option></select></div>\n <div class=\"rp-field\"><label>LLM model</label><input id=\"rpve-llm-model\"></div>\n <div class=\"rp-field\"><label>LLM API key</label><input type=\"password\" id=\"rpve-llm-key\" placeholder=\"(uses agent's own if blank)\"></div>\n <div class=\"rp-field\"><label>Temperature</label><input id=\"rpve-temperature\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label>Context size (num_ctx)</label><input id=\"rpve-num-ctx\" type=\"number\" value=\"8192\"></div>\n <div class=\"rp-field\"><label>TTS provider</label><select id=\"rpve-tts-provider\"><option value=\"elevenlabs\">elevenlabs</option><option value=\"kokoro\">kokoro</option><option value=\"piper\">piper</option></select></div>\n <div class=\"rp-field\"><label>TTS voice</label><input id=\"rpve-tts-voice\"></div>\n <div class=\"rp-field\"><label>TTS language</label><input id=\"rpve-tts-language\" placeholder=\"en\"></div>\n <div class=\"rp-field\"><label>Max turns</label><input id=\"rpve-max-turns\" type=\"number\" value=\"20\"></div>\n <div class=\"rp-field\"><label>Min endpointing delay (s)</label><input id=\"rpve-endpointing\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-interruptions\" checked style=\"width:auto;margin-right:0.35rem;\">Allow interruptions</label></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-wake-enabled\" style=\"width:auto;margin-right:0.35rem;\">Wake word enabled</label></div>\n <div class=\"rp-field\"><label>Wake word model</label><input id=\"rpve-wake-model\" placeholder=\"models/hey_jarvis.onnx\"></div>\n <div class=\"rp-field\"><label>Wake threshold</label><input id=\"rpve-wake-threshold\" type=\"number\" step=\"0.05\" value=\"0.5\"></div>\n <div class=\"rp-field\"><label>Wake timeout (s)</label><input id=\"rpve-wake-timeout\" type=\"number\" step=\"0.5\" value=\"3.0\"></div>\n <div style=\"grid-column:1/-1;display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpve-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpve-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Character presets</h4>\n <div id=\"rp-char-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newchar-name\" placeholder=\"new preset name\" style=\"flex:1;min-width:0;\">\n <input id=\"rp-newchar-stack\" list=\"rp-newchar-stack-list\" placeholder=\"voice stack id\" style=\"flex:1;min-width:0;\">\n <datalist id=\"rp-newchar-stack-list\"></datalist>\n <button class=\"rp-btn\" id=\"rp-addchar\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-char-edit\" style=\"display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"margin:0 0 0.2rem;\">Edit character: <span id=\"rpce-name-label\"></span></h4>\n <div class=\"rp-field\"><label>Name</label><input id=\"rpce-name\"></div>\n <div class=\"rp-field\"><label>Description</label><input id=\"rpce-description\"></div>\n <div class=\"rp-field\"><label>System prompt</label><textarea id=\"rpce-prompt\" rows=\"4\" style=\"font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;\"></textarea></div>\n <div class=\"rp-field\"><label>Voice stack</label><select id=\"rpce-stack\"></select></div>\n <div class=\"rp-field\"><label>Motors (comma-separated)</label><input id=\"rpce-motors\" placeholder=\"drive, wave\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad X (0-1)</label><input id=\"rpce-nx\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad Y (0-1)</label><input id=\"rpce-ny\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n </div>\n <div class=\"rp-field\"><label>Avatar image path</label><input id=\"rpce-img\" placeholder=\"/static/robopark/jaguar.png\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpce-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpce-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n <button class=\"act ghost\" id=\"updateBtn\" title=\"npm-update every node to the latest release and restart (supervised nodes come back on their own)\">Update fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastStatus = null; // most recent /fed/status snapshot (shared with Park)\n var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)\n var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)\n var FALLBACK_SLOTS = [\n {label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},\n {label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},\n {label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},\n {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},\n {label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}\n ];\n var parkSlots = FALLBACK_SLOTS.slice();\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme (dark glass is the default; toggle to frosted light)\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n document.documentElement.setAttribute('data-theme', s || 'dark');\n $('theme').addEventListener('click', function(){\n var cur=document.documentElement.getAttribute('data-theme')||'dark';\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e;\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n // \u2500\u2500 per-node hardware store + rolling history (drives the Fleet cards + sparklines) \u2500\u2500\n var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)\n var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers\n var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence\n function netTotal(hw){\n 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;\n }\n function netRxTx(hw){\n 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};\n }\n function pushHist(id, snap){\n var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});\n var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;\n var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;\n h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();\n h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();\n h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();\n }\n function updateHw(s){\n if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }\n var act = s.activity||[];\n 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; } }\n var nodes = s.nodes||[];\n for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }\n }\n // Inline sparkline (SVG polyline) from a numeric history buffer.\n function sparkSvg(arr, color, maxv){\n if(!arr || arr.length<2) return '';\n var w=100,h=20,n=arr.length, vals=[];\n for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);\n var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;\n 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); }\n return '<svg class=\"spark\" viewBox=\"0 0 '+w+' '+h+'\" preserveAspectRatio=\"none\" aria-hidden=\"true\">'\n + '<polyline points=\"'+pts+'\" fill=\"none\" stroke=\"'+color+'\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/></svg>';\n }\n function fmtRate(b){ return fmtBytes(b)+'/s'; }\n function fmtUptime(sec){ if(sec==null) return '\u2014'; 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'; }\n function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n 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'):''); }\n if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'\u00B0C'); }\n 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)); }\n var nt=netRxTx(hw); html += '<div class=\"stats\" style=\"margin-top:0.55rem\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span><span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function mrow(label, pct, valTxt, cls){\n var v = Math.max(0, Math.min(100, Math.round(pct||0)));\n return '<div class=\"mrow\"><span class=\"ml\">'+label+'</span>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div>'\n + '<span class=\"mv '+(cls||'')+'\">'+esc(valTxt)+'</span></div>';\n }\n function renderFleet(nodes){\n var el = $('fleet');\n 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; }\n // connected first, then robots/satellites above hubs so the fleet reads robot-first\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var accent = '#6FE0A6';\n try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var up = !!n.connected;\n var hw = hwByNode[n.nodeId] || null;\n var hist = hwHist[n.nodeId] || {};\n var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);\n var cores = hw&&hw.cpu ? hw.cpu.cores : null;\n var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;\n var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;\n var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;\n var nt = netRxTx(hw);\n var upt = hw&&hw.system ? hw.system.uptimeSec : null;\n var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n\n 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)+'\">'\n + '<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>'\n + '<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>';\n\n if(up){\n html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))\n + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')\n + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')\n + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'\u00B0C', tempClass(temp)) : '')\n + '<div class=\"stats\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span>'\n + '<span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')\n + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')\n + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'\n + '</div>';\n } else {\n html += '<div class=\"stats\"><span style=\"color:var(--muted)\">offline'+(n.lastSeenAt?(' \u00B7 last seen '+clock(n.lastSeenAt)):'')+'</span></div>';\n }\n html += (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'') + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n // live aggregates + alerts from per-node hardware\n var hotTemp=null, maxCpu=0, alerts=[];\n for(var k=0;k<nodes.length;k++){\n var nn=nodes[k]; if(!nn.connected) continue;\n var hw=hwByNode[nn.nodeId]; if(!hw) continue;\n if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);\n if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;\n if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'\u00B0C');\n if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');\n if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');\n 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+'%');\n }\n if(down>0) alerts.unshift(down+' offline');\n var html = '<span class=\"chip ok\">up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>'\n + '<span class=\"chip\">peak cpu <b>'+maxCpu+'%</b></span>'\n + (hotTemp!=null?('<span class=\"chip\">hottest <b>'+Math.round(hotTemp)+'\u00B0C</b></span>'):'');\n for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class=\"chip alert\">\u26A0 <b>'+esc(alerts[a2])+'</b></span>'; }\n $('summary').innerHTML = html;\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true); lastStatus = s;\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n updateHw(s);\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n Park.onStatus(s);\n }).catch(function(){ setLive(false); });\n }\n\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n $('updateBtn').addEventListener('click', function(){\n if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + Park + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-park').classList.toggle('active', name==='park');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var initHash = location.hash.replace('#','');\n // Defer tab init until Park module is defined (same outer IIFE, later in source).\n function initTab(){\n if (initHash==='robopark'||initHash==='park') selectTab(initHash);\n }\n // initTab() is called at the very bottom of this IIFE, after Park is assigned.\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null;\n if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n if(act==='trigger') triggerSession(id);\n if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }\n return;\n }\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n if(s.dataset.changed) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(!sid || cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n $('rp-grid').addEventListener('change', function(e){\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n\n var characterPresets=[];\n function loadCharacterPresets(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});\n }\n function loadVoiceStacks(){\n fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});\n }\n function loadParkSlots(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=Array.isArray(d)?d:(d&&d.presets)||[];\n var mapped=[];\n for(var i=0;i<list.length;i++){ var p=list[i];\n var id=p.id||p.preset_id||p.character_id||'';\n if(!id) continue;\n mapped.push({\n label:p.name||id.toUpperCase(), id:id,\n keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/s+/)).filter(function(x){return !!x;}),\n nx:(p.nx!=null?p.nx:0.5), ny:(p.ny!=null?p.ny:0.5),\n sub:p.sub||'', img:p.img||''\n });\n }\n parkSlots = mapped.length ? mapped : FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n }).catch(function(){\n parkSlots = FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n });\n }\n function fillCharacterDropdowns(){\n var sels=document.querySelectorAll('.rp-char');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<characterPresets.length;j++){ var p=characterPresets[j];\n var o=document.createElement('option'); o.value=p.id||p.preset_id||p.character_id||''; o.textContent=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):''); if((o.value)===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n function fillVoiceStackDropdowns(){\n var sels=document.querySelectorAll('.rp-voice');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<voiceStacks.length;j++){ var s=voiceStacks[j];\n var o=document.createElement('option'); o.value=s.id||''; o.textContent=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):''); if(o.value===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n\n function addServer(){\n var body={\n id:$('srv-id').value.trim(), name:$('srv-name').value.trim(), url:$('srv-url').value.trim(),\n api_key:$('srv-key').value.trim(), api_secret:$('srv-secret').value,\n max_sessions:parseInt($('srv-max').value,10)||8\n };\n if(!body.id||!body.name||!body.url){ showOut('Server ID, name, and URL are required'); return; }\n body.webhook_url=body.url;\n rpAction('POST','/robopark/api/servers',body).then(function(j){ showOut('server added: '+esc((j&&j.id)||body.id)); clearServerForm(); }).catch(function(e){ showOut('add server: '+e.message); });\n }\n function clearServerForm(){ $('srv-id').value=''; $('srv-name').value=''; $('srv-url').value=''; $('srv-key').value=''; $('srv-secret').value=''; $('srv-max').value='8'; }\n function addRobot(){\n var body={\n name:$('rb-name').value.trim(), character_id:$('rb-char').value||null,\n tailscale_ip:$('rb-tsip').value.trim()||null, lan_ip:$('rb-lanip').value.trim()||null,\n motor_server_url:$('rb-motor').value.trim()||null, livekit_url:$('rb-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n if(!body.name){ showOut('Robot name is required'); return; }\n rpAction('POST','/robopark/api/devices',body).then(function(j){ showOut('robot added: '+esc((j&&j.name)||body.name)+'\\ntoken: '+esc((j&&j.enrollment_token)||'(none)')); clearRobotForm(); pollRobopark(); }).catch(function(e){ showOut('add robot: '+e.message); });\n }\n function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; }\n function triggerSession(id){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });\n }\n function updateRobotCharacter(id, cid){\n if(!cid){ showOut('Choose a character preset'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(id)+'/voice-config',{character_id:cid}).then(function(){ showOut('updated '+id+' character to '+cid); pollRobopark(); }).catch(function(e){ showOut('voice-config ('+id+'): '+e.message); });\n }\n $('srv-add').addEventListener('click', addServer);\n $('rb-add').addEventListener('click', addRobot);\n $('rp-addvoice').addEventListener('click', function(){ addVoiceStackFromTemplate(); });\n $('rp-addchar').addEventListener('click', function(){ addCharacterFromTemplate(); });\n\n function addVoiceStackFromTemplate(){\n var name=$('rp-newvoice-name').value.trim(); if(!name){ showOut('Voice stack name required'); return; }\n rpAction('POST','/robopark/api/voice-stacks',{name:name}).then(function(j){ showOut('voice stack added: '+esc(j.id||name)); $('rp-newvoice-name').value=''; loadVoiceStacks(); }).catch(function(e){ showOut('add voice stack: '+e.message); });\n }\n function addCharacterFromTemplate(){\n var name=$('rp-newchar-name').value.trim(); if(!name){ showOut('Preset name required'); return; }\n var voice_stack_id=$('rp-newchar-stack').value.trim()||null;\n var id=name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,32);\n rpAction('POST','/robopark/api/character-presets',{id:id,name:name,voice_stack_id:voice_stack_id,motors:['drive']}).then(function(j){ showOut('character preset added: '+esc(j.id||id)); $('rp-newchar-name').value=''; $('rp-newchar-stack').value=''; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('add character preset: '+e.message); });\n }\n function fillTemplateVoiceList(){\n var el=$('rp-voice-list'); if(!el) return;\n var html='';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class=\"rp-card\" data-vsid=\"'+esc(s.id)+'\" style=\"padding:0.5rem 0.6rem;border-left-color:var(--info);cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(s.name||s.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">'+esc(s.stt_provider)+' \u2192 '+esc(s.llm_provider)+'/'+esc(s.llm_model||'')+' \u2192 '+esc(s.tts_provider)+'</div></div>'; }\n if(!voiceStacks.length) html='<div class=\"pk-note\">no voice stacks loaded</div>';\n el.innerHTML=html;\n var dl=$('rp-newchar-stack-list'); if(dl){ dl.innerHTML=''; for(var j=0;j<voiceStacks.length;j++){ var o=document.createElement('option'); o.value=voiceStacks[j].id; dl.appendChild(o); } }\n }\n function fillTemplateCharacterList(){\n var el=$('rp-char-list'); if(!el) return;\n var html='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class=\"rp-card\" data-cpid=\"'+esc(p.id)+'\" style=\"padding:0.5rem 0.6rem;cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(p.name||p.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">stack: '+esc(p.voice_stack_id||'\u2014')+' \u00B7 motors: '+esc((p.motors||[]).join(', '))+(p.system_prompt?' \u00B7 has prompt':' \u00B7 no prompt set')+'</div></div>'; }\n if(!characterPresets.length) html='<div class=\"pk-note\">no character presets loaded</div>';\n el.innerHTML=html;\n }\n\n // \u2500\u2500 voice-stack edit panel \u2500\u2500\n var editingVoiceStackId=null;\n function openVoiceStackEdit(id){\n var s=null; for(var i=0;i<voiceStacks.length;i++){ if(voiceStacks[i].id===id){ s=voiceStacks[i]; break; } }\n if(!s) return;\n editingVoiceStackId=id;\n $('rpve-name-label').textContent=s.name||s.id;\n $('rpve-stt-provider').value=s.stt_provider||'speaches';\n $('rpve-stt-model').value=s.stt_model||'';\n $('rpve-stt-language').value=s.stt_language||'en';\n $('rpve-llm-provider').value=s.llm_provider||'ollama';\n $('rpve-llm-model').value=s.llm_model||'';\n $('rpve-llm-key').value=s.llm_api_key||'';\n $('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);\n $('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);\n $('rpve-tts-provider').value=s.tts_provider||'elevenlabs';\n $('rpve-tts-voice').value=s.tts_voice||'';\n $('rpve-tts-language').value=s.tts_language||'en';\n $('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);\n $('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);\n $('rpve-interruptions').checked=!!s.allow_interruptions;\n $('rpve-wake-enabled').checked=!!s.wake_word_enabled;\n $('rpve-wake-model').value=s.wake_word_model||'';\n $('rpve-wake-threshold').value=(s.wake_word_threshold!=null?s.wake_word_threshold:0.5);\n $('rpve-wake-timeout').value=(s.wake_word_timeout!=null?s.wake_word_timeout:3.0);\n $('rp-voice-edit').style.display='grid';\n }\n function saveVoiceStackEdit(){\n if(!editingVoiceStackId) return;\n var body={\n name:$('rpve-name-label').textContent,\n stt_provider:$('rpve-stt-provider').value, stt_model:$('rpve-stt-model').value.trim(), stt_language:$('rpve-stt-language').value.trim()||'en',\n llm_provider:$('rpve-llm-provider').value, llm_model:$('rpve-llm-model').value.trim(), llm_api_key:$('rpve-llm-key').value.trim()||null,\n temperature:parseFloat($('rpve-temperature').value)||0.7, num_ctx:parseInt($('rpve-num-ctx').value,10)||8192,\n tts_provider:$('rpve-tts-provider').value, tts_voice:$('rpve-tts-voice').value.trim(), tts_language:$('rpve-tts-language').value.trim()||'en',\n max_turns:parseInt($('rpve-max-turns').value,10)||20, min_endpointing_delay:parseFloat($('rpve-endpointing').value)||0.7,\n allow_interruptions:$('rpve-interruptions').checked, wake_word_enabled:$('rpve-wake-enabled').checked,\n wake_word_model:$('rpve-wake-model').value.trim()||null, wake_word_threshold:parseFloat($('rpve-wake-threshold').value)||0.5,\n wake_word_timeout:parseFloat($('rpve-wake-timeout').value)||3.0\n };\n rpAction('PUT','/robopark/api/voice-stacks/'+encodeURIComponent(editingVoiceStackId),body).then(function(){ showOut('voice stack saved'); $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; loadVoiceStacks(); }).catch(function(e){ showOut('save voice stack: '+e.message); });\n }\n var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });\n var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);\n var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });\n\n // \u2500\u2500 character-preset edit panel \u2500\u2500\n var editingCharPresetId=null;\n function fillCharEditStackOptions(cur){\n var sel=$('rpce-stack'); if(!sel) return;\n sel.innerHTML='<option value=\"\">\u2014 default \u2014</option>';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; var o=document.createElement('option'); o.value=s.id; o.textContent=s.name||s.id; if(s.id===cur) o.selected=true; sel.appendChild(o); }\n }\n function openCharPresetEdit(id){\n var p=null; for(var i=0;i<characterPresets.length;i++){ if(characterPresets[i].id===id){ p=characterPresets[i]; break; } }\n if(!p) return;\n editingCharPresetId=id;\n $('rpce-name-label').textContent=p.name||p.id;\n $('rpce-name').value=p.name||'';\n $('rpce-description').value=p.description||'';\n $('rpce-prompt').value=p.system_prompt||'';\n fillCharEditStackOptions(p.voice_stack_id);\n $('rpce-motors').value=(p.motors||[]).join(', ');\n $('rpce-nx').value=(p.nx!=null?p.nx:0.5);\n $('rpce-ny').value=(p.ny!=null?p.ny:0.5);\n $('rpce-img').value=p.img||'';\n $('rp-char-edit').style.display='grid';\n }\n function saveCharPresetEdit(){\n if(!editingCharPresetId) return;\n var motors=$('rpce-motors').value.split(',').map(function(x){return x.trim();}).filter(function(x){return !!x;});\n var body={\n name:$('rpce-name').value.trim()||editingCharPresetId, description:$('rpce-description').value.trim()||null,\n system_prompt:$('rpce-prompt').value.trim()||null, voice_stack_id:$('rpce-stack').value||null,\n motors:motors, nx:parseFloat($('rpce-nx').value), ny:parseFloat($('rpce-ny').value), img:$('rpce-img').value.trim()||null\n };\n rpAction('PUT','/robopark/api/character-presets/'+encodeURIComponent(editingCharPresetId),body).then(function(){ showOut('character preset saved'); $('rp-char-edit').style.display='none'; editingCharPresetId=null; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('save character preset: '+e.message); });\n }\n var cl=$('rp-char-list'); if(cl) cl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id) openCharPresetEdit(id); });\n var cpSave=$('rpce-save'); if(cpSave) cpSave.addEventListener('click', saveCharPresetEdit);\n var cpCancel=$('rpce-cancel'); if(cpCancel) cpCancel.addEventListener('click', function(){ $('rp-char-edit').style.display='none'; editingCharPresetId=null; });\n\n function renderRobotCardSelect(name, current){\n var sel=$('rp-char-'+name); if(!sel) return;\n sel.dataset.current=current||'';\n fillCharacterDropdowns();\n }\n\n function pollRobopark(){\n pollSettings();\n loadCharacterPresets();\n loadVoiceStacks();\n loadParkSlots();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n // stash per-robot rows so the Park drawer can show live session stats\n roboparkByName = {};\n robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\" data-name=\"'+esc(name)+'\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn\" data-act=\"trigger\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Trigger motion</button>'\n + '<button class=\"rp-btn\" data-act=\"preview\" data-robot=\"'+idAttr+'\">Preview</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div>'\n + '<div class=\"rp-field\" style=\"margin-top:0.45rem\"><label style=\"font-size:0.64rem\">Character preset</label><select class=\"rp-char\" data-act=\"char\" data-robot=\"'+idAttr+'\"><option value=\"\">'+esc(x.character_id||x.character_preset_id||'\u2014 auto \u2014')+'</option></select></div></div>';\n }\n grid.innerHTML = html;\n fillCharacterDropdowns();\n var sels=document.querySelectorAll('.rp-char');\n for(var si=0;si<sels.length;si++){ var ss=sels[si]; var current=ss.value; ss.dataset.current=current; }\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n // Park \u2014 isometric live map of the mesh\n // Real data: /fed/status nodes (placement + load) + /fed/stream frames\n // (agent movement + tool-use pulses) + /robopark telemetry (session stats).\n // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n var Park = (function(){\n var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial photo): named robot pads at their ground\n // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,\n // street and stage. A mesh node binds to a pad when its display name matches\n // the pad keywords (robobmw \u2192 VIXEN/BMW).\n // SLOTS is now rebuilt from scheduler character_presets; keep the same shape.\n var SLOTS = parkSlots.slice();\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};\n // Palm positions tuned to the aerial: clusters along the back edge and\n // scattered down the center of the paved strip.\n var PALMS=[\n [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],\n [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],\n [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],\n [0.62,0.80],[0.76,0.84],[0.35,0.82]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};\n // Street traffic along the top road (yellow taxis + private cars).\n var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\n function kindOf(n){\n var r=(n.role||'').toLowerCase();\n if(r==='hub'||r==='scheduler') return 'hub';\n if(r==='satellite'||r==='robot') return 'robot';\n var caps=(n.capabilities||[]).join(' ');\n if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';\n return 'peer';\n }\n function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }\n function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }\n\n // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n 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; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\n });\n return out;\n }\n\n function onStatus(s){\n if(!s) return;\n nodes = layout(s.nodes||[]);\n byId={}; nodes.forEach(function(n){ byId[n.id]=n; });\n // if not streaming, animate from the polled activity tail\n if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }\n }\n\n function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }\n function findByFrame(f){\n var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\n return null;\n }\n function ingest(f, fromPoll){\n if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }\n var n=findByFrame(f), h=hub();\n if(n && h){\n if(n.kind==='hub'){ pulse(n, glyphFor(f)); }\n else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }\n else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }\n }\n feedRow(f);\n }\n function glyphFor(f){\n if(f.t==='rt') return '\u21C4';\n if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '\u2328'; if(/session|presence/.test(k)) return '\uD83C\uDF99'; return '\u2726'; }\n if(f.t==='hw'||f.t==='hb') return '';\n return '\u2726';\n }\n function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }\n function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'\u2726'}); }\n\n function feedRow(f){\n var rows=$('pk-rows'); if(!rows) return;\n var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;\n var r=document.createElement('div'); r.className='pk-row';\n r.innerHTML='<span class=\"k pk-k-'+esc(kind)+'\"></span><span class=\"t\">'+clock(f.ts)+'</span>'\n +'<span class=\"who\" title=\"'+esc(f.n)+'\">'+esc((f.n||'').slice(0,10))+'</span>'\n +'<span class=\"msg\">'+esc(summarizeFrame(f))+'</span>';\n rows.insertBefore(r, rows.firstChild);\n while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);\n }\n\n // \u2500\u2500 canvas + projection \u2500\u2500\n function ensure(){\n if(C) return;\n C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();\n DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));\n var d=false,px,py;\n C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});\n window.addEventListener('mouseup',function(){d=false;});\n window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });\n C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});\n C.addEventListener('click',function(e){\n var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;\n 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]; }\n if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }\n });\n window.addEventListener('resize', function(){ if(running) resize(); });\n }\n function resize(){\n var st=$('pk-stage'); if(!st) return;\n W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);\n if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n 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); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n 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)');\n 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);\n // street band along the far edge + dashed centerline + cars\n 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);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n 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([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // white event tent / stage on the far left of the plaza (matches aerial)\n drawTent(0.85, 0.18);\n // paved plaza \u2014 long rectangular promenade matching the aerial footprint\n var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;\n 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)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n // plaza grid (tile joints)\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ 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(); }\n for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ 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(); }\n // curved pond / water feature at bottom-left\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }\n 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)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n // winding footpath along the pond\n X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);\n var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];\n X.beginPath();\n for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }\n X.stroke();\n }\n function drawTent(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // canopy shadow\n X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();\n // white peaked tent\n X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n // support poles\n X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;\n X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();\n X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n 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();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n 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();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n 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();\n 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();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n 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(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n 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();\n X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();\n }\n function label(cx,cy,txt,col,sub){\n var z=cam.z; X.textAlign='center';\n X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);\n X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);\n }\n 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(); }\n function drawHub(n){\n 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;\n 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();\n X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;\n X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();\n 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; }\n X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();\n X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();\n label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};\n }\n function drawRobot(n){\n 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;\n 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);\n 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();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n var img = n.connected && imgs[n.label];\n if(img && img.complete && img.naturalWidth>0){\n var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();\n X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);\n X.restore();\n X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n 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();\n 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();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n }\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n 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();\n var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';\n X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;\n X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();\n 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;\n label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));\n n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};\n }\n\n function frame(){\n if(!running) return;\n X.clearRect(0,0,W,H);\n drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n 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}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n 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); } });\n 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;}\n 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;\n 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;\n 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;\n }\n 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;}\n var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;\n 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);\n 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;\n }\n raf=requestAnimationFrame(frame);\n }\n\n function connectStream(){\n if(es) return;\n try{\n es=new EventSource('/fed/stream'+QS);\n es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };\n es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream \u00B7 live'; };\n es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status \u00B7 poll'; };\n }catch(e){ es=null; }\n }\n\n var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; };\n // The SDK is ~500KB \u2014 a camera view or \"Go live\" click can easily fire\n // before it finishes loading. Without this, that first attempt sees\n // window.LivekitClient===undefined, falls into the permanent \"no-sdk\"\n // idle state, and never retries even once the SDK is ready.\n s.onload=function(){\n sdkLoading=false;\n if(!drawerId) return;\n var n=byId[drawerId];\n if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }\n };\n document.head.appendChild(s);\n }\n function loadImages(){\n SLOTS.forEach(function(s){ if(!s.img) return; var i=new Image(); i.src=s.img+QS; i.onload=function(){ imgs[s.label]=i; }; i.onerror=function(){ imgs[s.label]=null; }; });\n }\n function updateSlots(newSlots){\n SLOTS = newSlots.slice();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n }\n function activate(){\n ensure();\n loadSDK();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n resize();\n connectStream();\n if(!running){ running=true; raf=requestAnimationFrame(frame); }\n }\n function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }\n\n // \u2500\u2500 robot drawer \u2500\u2500\n function sect(title, inner){ return '<div class=\"pk-sect\"><h4>'+title+'</h4>'+inner+'</div>'; }\n function openDrawer(id){\n var n=byId[id]; if(!n) return; drawerId=id;\n $('pk-drawer').classList.add('open');\n $('pk-av').textContent = n.kind==='hub'?'\uD83D\uDDA5':(n.kind==='robot'?'\uD83E\uDD16':'\uD83E\uDDE9');\n $('pk-name').textContent=n.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');\n buildDrawer(n);\n pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick\n if(drawerTimer) clearInterval(drawerTimer);\n drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);\n }\n // Resolve a mesh node's *scheduler* robot id \u2014 n.name is the mesh display\n // name, which is not necessarily the scheduler's robots.id (e.g. an\n // enrolled device's id is a generated dev_xxxx, distinct from its name).\n // Falls back to n.name if telemetry hasn't matched a row yet.\n function schedRow(n){\n var row=roboparkByName[n.name];\n 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; } }\n return row;\n }\n function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }\n function closeDrawer(){\n // tell the scheduler to end any live preview so the robot stops its cam\n if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }\n previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n stopVision();\n $('pk-drawer').classList.remove('open'); drawerId=null;\n if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;\n if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;\n if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }\n if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=null;\n }\n\n // \u2500\u2500 setup flow for unjoined park pads \u2500\u2500\n function submitPadSetup(n){\n var name=$('pk-setup-name').value.trim() || (n.label||n.id||'robot').toString().toLowerCase();\n var body={\n name:name,\n character_id:$('pk-setup-char').value||null,\n tailscale_ip:$('pk-setup-tsip').value.trim()||null,\n lan_ip:$('pk-setup-lanip').value.trim()||null,\n motor_server_url:$('pk-setup-motor').value.trim()||null,\n livekit_url:$('pk-setup-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n rpAction('POST','/robopark/api/devices',body).then(function(j){\n var token=(j&&j.enrollment_token)||'';\n $('pk-setup-token').value=token;\n $('pk-setup-result').style.display='block';\n showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));\n pollRoboparkQuiet();\n }).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });\n }\n\n // \u2500\u2500 effective voice config for connected robots \u2500\u2500\n function loadEffectiveConfig(robotId){\n fetch('/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(cfg){\n var note=$('pk-eff-note'); if(note) note.textContent='Effective config: '+(cfg&&cfg.character_name?esc(cfg.character_name):'\u2014')+' / '+(cfg&&cfg.voice_stack&&cfg.voice_stack.name?esc(cfg.voice_stack.name):'\u2014');\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n if(csel){ csel.value=(cfg&&cfg.character_id)||''; csel.dataset.current=csel.value; }\n if(vsel){ vsel.value=(cfg&&cfg.voice_stack_id)||''; vsel.dataset.current=vsel.value; }\n var saveBtn=$('pk-eff-save');\n if(saveBtn && !saveBtn._bound){ saveBtn._bound=true; saveBtn.addEventListener('click', function(){ applyEffectiveConfig(robotId); }); }\n }).catch(function(){\n var note=$('pk-eff-note'); if(note) note.textContent='effective config unavailable';\n });\n }\n function applyEffectiveConfig(robotId){\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n var body={};\n if(csel && csel.value) body.character_preset_id=csel.value;\n if(vsel && vsel.value) body.voice_stack_id=vsel.value;\n if(!body.character_preset_id && !body.voice_stack_id){ showOut('Choose a character preset or voice stack'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config',body).then(function(cfg){\n showOut('updated robot config: '+(cfg&&cfg.character_name||robotId));\n if(csel) csel.dataset.current=csel.value;\n if(vsel) vsel.dataset.current=vsel.value;\n }).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });\n }\n\n // \u2500\u2500 on-demand operator preview: open the robot's cam without a scene-detection session \u2500\u2500\n function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'\u25A0 Stop live':'\u25B6 Go live'; b.classList.toggle('danger', previewOn); } }\n function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n var note=$('pk-golivenote'); if(note) note.textContent='requesting preview\u2026';\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first\n idleCam('connecting');\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(!previewOn) return; // stopped while awaiting\n if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }\n if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview \u00B7 robot opening camera\u2026'; }\n else { idleCam('no-sdk'); }\n if(previewTimer) clearInterval(previewTimer);\n previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);\n })\n .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });\n }\n function stopPreview(n){\n previewOn=false; updateGoLive();\n if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});\n var note=$('pk-golivenote'); if(note) note.textContent='';\n idleCam('idle');\n }\n // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),\n // so the browser hits the robot's LAN IP straight on, no scheduler proxy.\n // The scheduler learns that IP from the device's own heartbeats.\n function toggleVision(n){\n var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');\n if(visionOn){\n visionOn=false; if(img){ img.src=''; img.style.display='none'; }\n if(btn) btn.textContent='\uD83D\uDD0D Show overlay';\n if(note) note.textContent=\"object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)\";\n return;\n }\n var row=schedRow(n);\n var ip=row&&row.lan_ip;\n if(!ip){ if(note) note.textContent='no known IP for this robot yet \u2014 waiting for a heartbeat with lan_ip'; return; }\n var port=(row&&row.vision_port)||5000;\n visionOn=true;\n if(btn) btn.textContent='\u25A0 Hide overlay';\n if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';\n if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }\n }\n function stopVision(){\n visionOn=false;\n var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }\n }\n function pollRoboparkQuiet(){\n fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};\n list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function buildDrawer(n){\n var b=$('pk-body');\n if(n.kind==='robot' && !n.connected){\n var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/s+/g,'');\n var presetOptions='', voiceOptions='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; presetOptions += '<option value=\"'+esc(p.id||'')+'\"'+((p.id===n.id)?' selected':'')+'>'+esc(p.name||p.id||'')+'</option>'; }\n for(var v=0;v<voiceStacks.length;v++){ var s=voiceStacks[v]; voiceOptions += '<option value=\"'+esc(s.id||'')+'\">'+esc(s.name||s.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label),\n '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted);margin-bottom:0.55rem;\">Set up this pad before a robot joins it.</div>'\n + '<div class=\"pk-grid2\" style=\"margin-bottom:0.55rem;\">'\n + '<div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div>'\n + '<div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div>'\n + '</div>'\n + '<div class=\"rp-form\" style=\"padding:0.55rem;grid-template-columns:1fr;gap:0.5rem;margin:0;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Robot name</label><input id=\"pk-setup-name\" value=\"'+esc(defaultName)+'\"></div>'\n + '<div class=\"rp-field\"><label>Character preset</label><select id=\"pk-setup-char\"><option value=\"\">\u2014 auto \u2014</option>'+presetOptions+'</select></div>'\n + '<div class=\"rp-field\"><label>Tailscale IP</label><input id=\"pk-setup-tsip\" placeholder=\"100.x.x.x\"></div>'\n + '<div class=\"rp-field\"><label>LAN IP</label><input id=\"pk-setup-lanip\" placeholder=\"192.168.x.x\"></div>'\n + '<div class=\"rp-field\"><label>Motor server URL</label><input id=\"pk-setup-motor\" placeholder=\"http://...:5000\"></div>'\n + '<div class=\"rp-field\"><label>LiveKit URL</label><input id=\"pk-setup-lk\" placeholder=\"ws://...:7880\"></div>'\n + '<div class=\"rp-field\"><label>Voice stack</label><select id=\"pk-setup-voice\"><option value=\"\">\u2014 default \u2014</option>'+voiceOptions+'</select></div>'\n + '<button class=\"rp-btn\" id=\"pk-setup-btn\" data-preset=\"'+esc(n.id)+'\" style=\"width:fit-content;\">Mint enrollment token & add robot</button>'\n + '</div>'\n + '<div class=\"pk-sect\" id=\"pk-setup-result\" style=\"display:none;\"><h4>Enrollment token (copy once)</h4><input id=\"pk-setup-token\" class=\"rp-char\" readonly style=\"margin-bottom:0.4rem;\"><button class=\"rp-btn\" id=\"pk-setup-copy\">Copy</button></div>');\n var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });\n 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'); } });\n return;\n }\n if(n.kind==='robot'){\n var effPresetOptions='', effVoiceOptions='';\n for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value=\"'+esc(cp.id||'')+'\"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }\n for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value=\"'+esc(vs.id||'')+'\">'+esc(vs.name||vs.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83D\uDC41 camera feed',\n '<div class=\"pk-cam\" id=\"pk-cam\"><canvas id=\"pk-camcv\" width=\"384\" height=\"216\"></canvas>'\n +'<div class=\"hud\"><div class=\"rec\"><i></i>REC \u00B7 '+esc(n.name)+'</div><div class=\"tr\" id=\"pk-camres\">\u2014 \u00B7 \u2014</div><div class=\"br\" id=\"pk-camts\">--:--:--</div></div></div>'\n +'<div class=\"pk-note\" id=\"pk-camnote\">seeking LiveKit track\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u25B6 Go live</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\"></span></div>')\n + sect('\uD83D\uDD0D vision overlay',\n '<div class=\"pk-cam\" id=\"pk-vision-wrap\"><img id=\"pk-vision-img\" style=\"width:100%;display:none;border-radius:inherit\" alt=\"vision feed\"/></div>'\n +'<div class=\"pk-note\" id=\"pk-vision-note\">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-vision-toggle\">\uD83D\uDD0D Show overlay</button></div>')\n + sect('\uD83C\uDF99 microphone','<div class=\"pk-meter\"><span class=\"lab\">input</span><div class=\"pk-bars\" id=\"pk-micbars\"></div><span class=\"pk-val\" id=\"pk-micval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-micstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDD0A speaker','<canvas class=\"pk-wave\" id=\"pk-spkwave\" width=\"360\" height=\"32\"></canvas><div class=\"pk-meter\"><span class=\"lab\">output</span><div class=\"pk-bars\" id=\"pk-spkbars\"></div><span class=\"pk-val\" id=\"pk-spkval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-spkstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDCCA hardware','<div class=\"pk-hwgrid\"><div><div class=\"l\">load</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-load\"></div></div></div><div><div class=\"l\">status</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-conn\" style=\"background:'+(n.connected?'var(--ok)':'var(--muted)')+'\"></div></div></div></div><div class=\"pk-note\">remote node \u2014 full CPU/mem telemetry shows on the node itself</div>')\n + sect('\uD83D\uDDD3 session telemetry','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-trig\">\u2014</div><div class=\"l\">triggers</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-sess\">\u2014</div><div class=\"l\">sessions</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-lat\">\u2014</div><div class=\"l\">avg latency</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-drop\">\u2014</div><div class=\"l\">drop rate</div></div></div>')\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn danger\" id=\"pk-end\" data-robot=\"'+esc(n.name)+'\">End session</button><button class=\"rp-btn\" id=\"pk-follow\">Follow run</button></div><div class=\"pk-note\">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>')\n + sect('\uD83C\uDFAD character / voice','<div class=\"pk-note\" id=\"pk-eff-note\" style=\"margin-bottom:0.45rem;\">loading effective config\u2026</div><div class=\"rp-field\"><label>Character preset</label><select class=\"rp-char\" id=\"pk-eff-char\" data-robot=\"'+esc(schedId(n))+'\">'+effPresetOptions+'</select></div><div class=\"rp-field\"><label>Voice stack</label><select class=\"rp-voice\" id=\"pk-eff-voice\" data-robot=\"'+esc(schedId(n))+'\">'+effVoiceOptions+'</select></div><button class=\"rp-btn\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\" style=\"margin-top:0.35rem;\">Set up on this robot</button>');\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n));\n var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });\n } else {\n b.innerHTML =\n sect('\uD83D\uDDA5 node','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-load\">\u2014</div><div class=\"l\">load</div></div><div class=\"pk-kv\"><div class=\"v '+(n.connected?'ok':'')+'\">'+(n.connected?'up':'down')+'</div><div class=\"l\">status</div></div></div>')\n + sect('\uD83D\uDD0C capabilities', (n.caps.length?('<div class=\"caps\">'+n.caps.map(function(c){return '<span class=\"cap\">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class=\"pk-note\">none reported</div>'))\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-status\">Run /status</button></div>');\n var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });\n }\n refreshDrawer();\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);\n var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';\n var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';\n var row=schedRow(n);\n if(row){\n set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);\n set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);\n set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'\u2014');\n 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+'%':'\u2014'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }\n var active=row.active_session||row.in_session||row.session_active;\n var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }\n }\n }\n function set(id,v){ var e=$(id); if(e) e.textContent=v; }\n function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n var note=$('pk-camnote');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n 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); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\n }\n function previewCam(n){\n var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();\n var res=$('pk-camres'); if(res && res.textContent==='\u2014 \u00B7 \u2014') res.textContent='preview \u00B7 24fps';\n (function loop(){ if(!$('pk-camcv')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n var g=cx.createLinearGradient(0,0,0,h); g.addColorStop(0,'#0a1420'); g.addColorStop(1,'#132233'); cx.fillStyle=g; cx.fillRect(0,0,w,h);\n cx.fillStyle='#0e1a12'; cx.beginPath(); cx.moveTo(0,h*0.62); cx.lineTo(w,h*0.62); cx.lineTo(w,h); cx.lineTo(0,h); cx.fill();\n var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;\n cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();\n cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);\n cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);\n cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);\n cx.globalAlpha=0.06; for(var y=0;y<h;y+=3){ cx.fillStyle=y%2?'#fff':'#000'; cx.fillRect(0,y,w,1); } cx.globalAlpha=1;\n for(var k=0;k<30;k++){ cx.fillStyle='rgba(255,255,255,'+(Math.random()*0.05)+')'; cx.fillRect(Math.random()*w,Math.random()*h,1,1); }\n var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());\n camRAF=requestAnimationFrame(loop);\n })();\n }\n 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'); }\n function startMeters(n){\n var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;\n var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n 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); }\n 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)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n 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(); }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n function openDrawerForRobot(name){\n var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i]; if(n.kind==='robot' && ((n.name||'').toLowerCase()===key || n.telemKeys.some(function(t){ return t===key; }))){ found=n; break; } }\n var node=found || { id:id, kind:'robot', name:name, telemKeys:[key], connected:true, label:name };\n if(!byId[id]){ byId[id]=node; nodes.push(node); }\n openDrawer(node.id);\n }\n\n $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);\n return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n initTab();\n})();\n</script>\n</body>\n</html>";
@@ -413,6 +413,31 @@ button.act:disabled{opacity:0.5;cursor:default;}
413
413
  <input id="rp-newvoice-name" placeholder="new stack name" style="flex:1;min-width:0;">
414
414
  <button class="rp-btn" id="rp-addvoice">Add</button>
415
415
  </div>
416
+ <div class="rp-form" id="rp-voice-edit" style="display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
417
+ <h4 style="grid-column:1/-1;margin:0 0 0.2rem;">Edit voice stack: <span id="rpve-name-label"></span></h4>
418
+ <div class="rp-field"><label>STT provider</label><select id="rpve-stt-provider"><option value="speaches">speaches</option><option value="groq">groq</option><option value="vibevoice">vibevoice</option></select></div>
419
+ <div class="rp-field"><label>STT model</label><input id="rpve-stt-model"></div>
420
+ <div class="rp-field"><label>STT language</label><input id="rpve-stt-language" placeholder="en / auto"></div>
421
+ <div class="rp-field"><label>LLM provider</label><select id="rpve-llm-provider"><option value="ollama">ollama</option><option value="groq">groq</option><option value="gemini">gemini</option><option value="moltbot">moltbot</option></select></div>
422
+ <div class="rp-field"><label>LLM model</label><input id="rpve-llm-model"></div>
423
+ <div class="rp-field"><label>LLM API key</label><input type="password" id="rpve-llm-key" placeholder="(uses agent's own if blank)"></div>
424
+ <div class="rp-field"><label>Temperature</label><input id="rpve-temperature" type="number" step="0.1" value="0.7"></div>
425
+ <div class="rp-field"><label>Context size (num_ctx)</label><input id="rpve-num-ctx" type="number" value="8192"></div>
426
+ <div class="rp-field"><label>TTS provider</label><select id="rpve-tts-provider"><option value="elevenlabs">elevenlabs</option><option value="kokoro">kokoro</option><option value="piper">piper</option></select></div>
427
+ <div class="rp-field"><label>TTS voice</label><input id="rpve-tts-voice"></div>
428
+ <div class="rp-field"><label>TTS language</label><input id="rpve-tts-language" placeholder="en"></div>
429
+ <div class="rp-field"><label>Max turns</label><input id="rpve-max-turns" type="number" value="20"></div>
430
+ <div class="rp-field"><label>Min endpointing delay (s)</label><input id="rpve-endpointing" type="number" step="0.1" value="0.7"></div>
431
+ <div class="rp-field"><label><input type="checkbox" id="rpve-interruptions" checked style="width:auto;margin-right:0.35rem;">Allow interruptions</label></div>
432
+ <div class="rp-field"><label><input type="checkbox" id="rpve-wake-enabled" style="width:auto;margin-right:0.35rem;">Wake word enabled</label></div>
433
+ <div class="rp-field"><label>Wake word model</label><input id="rpve-wake-model" placeholder="models/hey_jarvis.onnx"></div>
434
+ <div class="rp-field"><label>Wake threshold</label><input id="rpve-wake-threshold" type="number" step="0.05" value="0.5"></div>
435
+ <div class="rp-field"><label>Wake timeout (s)</label><input id="rpve-wake-timeout" type="number" step="0.5" value="3.0"></div>
436
+ <div style="grid-column:1/-1;display:flex;gap:0.4rem;">
437
+ <button class="rp-btn" id="rpve-save">Save</button>
438
+ <button class="rp-btn" id="rpve-cancel">Cancel</button>
439
+ </div>
440
+ </div>
416
441
  </div>
417
442
  <div>
418
443
  <h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Character presets</h4>
@@ -423,6 +448,23 @@ button.act:disabled{opacity:0.5;cursor:default;}
423
448
  <datalist id="rp-newchar-stack-list"></datalist>
424
449
  <button class="rp-btn" id="rp-addchar">Add</button>
425
450
  </div>
451
+ <div class="rp-form" id="rp-char-edit" style="display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
452
+ <h4 style="margin:0 0 0.2rem;">Edit character: <span id="rpce-name-label"></span></h4>
453
+ <div class="rp-field"><label>Name</label><input id="rpce-name"></div>
454
+ <div class="rp-field"><label>Description</label><input id="rpce-description"></div>
455
+ <div class="rp-field"><label>System prompt</label><textarea id="rpce-prompt" rows="4" style="font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;"></textarea></div>
456
+ <div class="rp-field"><label>Voice stack</label><select id="rpce-stack"></select></div>
457
+ <div class="rp-field"><label>Motors (comma-separated)</label><input id="rpce-motors" placeholder="drive, wave"></div>
458
+ <div style="display:flex;gap:0.4rem;">
459
+ <div class="rp-field" style="flex:1"><label>Pad X (0-1)</label><input id="rpce-nx" type="number" step="0.01" min="0" max="1"></div>
460
+ <div class="rp-field" style="flex:1"><label>Pad Y (0-1)</label><input id="rpce-ny" type="number" step="0.01" min="0" max="1"></div>
461
+ </div>
462
+ <div class="rp-field"><label>Avatar image path</label><input id="rpce-img" placeholder="/static/robopark/jaguar.png"></div>
463
+ <div style="display:flex;gap:0.4rem;">
464
+ <button class="rp-btn" id="rpce-save">Save</button>
465
+ <button class="rp-btn" id="rpce-cancel">Cancel</button>
466
+ </div>
467
+ </div>
426
468
  </div>
427
469
  </div>
428
470
  </div>
@@ -942,7 +984,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
942
984
  function fillTemplateVoiceList(){
943
985
  var el=$('rp-voice-list'); if(!el) return;
944
986
  var html='';
945
- for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class="rp-card" style="padding:0.5rem 0.6rem;border-left-color:var(--info)"><div style="font-family:var(--mono);font-size:0.74rem;font-weight:600;">'+esc(s.name||s.id)+'</div><div style="font-size:0.6rem;color:var(--muted);margin-top:0.15rem;">'+esc(s.stt_provider)+' → '+esc(s.llm_provider)+' → '+esc(s.tts_provider)+'</div></div>'; }
987
+ for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class="rp-card" data-vsid="'+esc(s.id)+'" style="padding:0.5rem 0.6rem;border-left-color:var(--info);cursor:pointer;"><div style="font-family:var(--mono);font-size:0.74rem;font-weight:600;">'+esc(s.name||s.id)+'</div><div style="font-size:0.6rem;color:var(--muted);margin-top:0.15rem;">'+esc(s.stt_provider)+' → '+esc(s.llm_provider)+'/'+esc(s.llm_model||'')+' → '+esc(s.tts_provider)+'</div></div>'; }
946
988
  if(!voiceStacks.length) html='<div class="pk-note">no voice stacks loaded</div>';
947
989
  el.innerHTML=html;
948
990
  var dl=$('rp-newchar-stack-list'); if(dl){ dl.innerHTML=''; for(var j=0;j<voiceStacks.length;j++){ var o=document.createElement('option'); o.value=voiceStacks[j].id; dl.appendChild(o); } }
@@ -950,11 +992,93 @@ button.act:disabled{opacity:0.5;cursor:default;}
950
992
  function fillTemplateCharacterList(){
951
993
  var el=$('rp-char-list'); if(!el) return;
952
994
  var html='';
953
- for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class="rp-card" style="padding:0.5rem 0.6rem;"><div style="font-family:var(--mono);font-size:0.74rem;font-weight:600;">'+esc(p.name||p.id)+'</div><div style="font-size:0.6rem;color:var(--muted);margin-top:0.15rem;">stack: '+esc(p.voice_stack_id||'—')+' · motors: '+esc((p.motors||[]).join(', '))+'</div></div>'; }
995
+ for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class="rp-card" data-cpid="'+esc(p.id)+'" style="padding:0.5rem 0.6rem;cursor:pointer;"><div style="font-family:var(--mono);font-size:0.74rem;font-weight:600;">'+esc(p.name||p.id)+'</div><div style="font-size:0.6rem;color:var(--muted);margin-top:0.15rem;">stack: '+esc(p.voice_stack_id||'—')+' · motors: '+esc((p.motors||[]).join(', '))+(p.system_prompt?' · has prompt':' · no prompt set')+'</div></div>'; }
954
996
  if(!characterPresets.length) html='<div class="pk-note">no character presets loaded</div>';
955
997
  el.innerHTML=html;
956
998
  }
957
999
 
1000
+ // ── voice-stack edit panel ──
1001
+ var editingVoiceStackId=null;
1002
+ function openVoiceStackEdit(id){
1003
+ var s=null; for(var i=0;i<voiceStacks.length;i++){ if(voiceStacks[i].id===id){ s=voiceStacks[i]; break; } }
1004
+ if(!s) return;
1005
+ editingVoiceStackId=id;
1006
+ $('rpve-name-label').textContent=s.name||s.id;
1007
+ $('rpve-stt-provider').value=s.stt_provider||'speaches';
1008
+ $('rpve-stt-model').value=s.stt_model||'';
1009
+ $('rpve-stt-language').value=s.stt_language||'en';
1010
+ $('rpve-llm-provider').value=s.llm_provider||'ollama';
1011
+ $('rpve-llm-model').value=s.llm_model||'';
1012
+ $('rpve-llm-key').value=s.llm_api_key||'';
1013
+ $('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);
1014
+ $('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);
1015
+ $('rpve-tts-provider').value=s.tts_provider||'elevenlabs';
1016
+ $('rpve-tts-voice').value=s.tts_voice||'';
1017
+ $('rpve-tts-language').value=s.tts_language||'en';
1018
+ $('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);
1019
+ $('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);
1020
+ $('rpve-interruptions').checked=!!s.allow_interruptions;
1021
+ $('rpve-wake-enabled').checked=!!s.wake_word_enabled;
1022
+ $('rpve-wake-model').value=s.wake_word_model||'';
1023
+ $('rpve-wake-threshold').value=(s.wake_word_threshold!=null?s.wake_word_threshold:0.5);
1024
+ $('rpve-wake-timeout').value=(s.wake_word_timeout!=null?s.wake_word_timeout:3.0);
1025
+ $('rp-voice-edit').style.display='grid';
1026
+ }
1027
+ function saveVoiceStackEdit(){
1028
+ if(!editingVoiceStackId) return;
1029
+ var body={
1030
+ name:$('rpve-name-label').textContent,
1031
+ stt_provider:$('rpve-stt-provider').value, stt_model:$('rpve-stt-model').value.trim(), stt_language:$('rpve-stt-language').value.trim()||'en',
1032
+ llm_provider:$('rpve-llm-provider').value, llm_model:$('rpve-llm-model').value.trim(), llm_api_key:$('rpve-llm-key').value.trim()||null,
1033
+ temperature:parseFloat($('rpve-temperature').value)||0.7, num_ctx:parseInt($('rpve-num-ctx').value,10)||8192,
1034
+ tts_provider:$('rpve-tts-provider').value, tts_voice:$('rpve-tts-voice').value.trim(), tts_language:$('rpve-tts-language').value.trim()||'en',
1035
+ max_turns:parseInt($('rpve-max-turns').value,10)||20, min_endpointing_delay:parseFloat($('rpve-endpointing').value)||0.7,
1036
+ allow_interruptions:$('rpve-interruptions').checked, wake_word_enabled:$('rpve-wake-enabled').checked,
1037
+ wake_word_model:$('rpve-wake-model').value.trim()||null, wake_word_threshold:parseFloat($('rpve-wake-threshold').value)||0.5,
1038
+ wake_word_timeout:parseFloat($('rpve-wake-timeout').value)||3.0
1039
+ };
1040
+ rpAction('PUT','/robopark/api/voice-stacks/'+encodeURIComponent(editingVoiceStackId),body).then(function(){ showOut('voice stack saved'); $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; loadVoiceStacks(); }).catch(function(e){ showOut('save voice stack: '+e.message); });
1041
+ }
1042
+ var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });
1043
+ var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);
1044
+ var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });
1045
+
1046
+ // ── character-preset edit panel ──
1047
+ var editingCharPresetId=null;
1048
+ function fillCharEditStackOptions(cur){
1049
+ var sel=$('rpce-stack'); if(!sel) return;
1050
+ sel.innerHTML='<option value="">— default —</option>';
1051
+ for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; var o=document.createElement('option'); o.value=s.id; o.textContent=s.name||s.id; if(s.id===cur) o.selected=true; sel.appendChild(o); }
1052
+ }
1053
+ function openCharPresetEdit(id){
1054
+ var p=null; for(var i=0;i<characterPresets.length;i++){ if(characterPresets[i].id===id){ p=characterPresets[i]; break; } }
1055
+ if(!p) return;
1056
+ editingCharPresetId=id;
1057
+ $('rpce-name-label').textContent=p.name||p.id;
1058
+ $('rpce-name').value=p.name||'';
1059
+ $('rpce-description').value=p.description||'';
1060
+ $('rpce-prompt').value=p.system_prompt||'';
1061
+ fillCharEditStackOptions(p.voice_stack_id);
1062
+ $('rpce-motors').value=(p.motors||[]).join(', ');
1063
+ $('rpce-nx').value=(p.nx!=null?p.nx:0.5);
1064
+ $('rpce-ny').value=(p.ny!=null?p.ny:0.5);
1065
+ $('rpce-img').value=p.img||'';
1066
+ $('rp-char-edit').style.display='grid';
1067
+ }
1068
+ function saveCharPresetEdit(){
1069
+ if(!editingCharPresetId) return;
1070
+ var motors=$('rpce-motors').value.split(',').map(function(x){return x.trim();}).filter(function(x){return !!x;});
1071
+ var body={
1072
+ name:$('rpce-name').value.trim()||editingCharPresetId, description:$('rpce-description').value.trim()||null,
1073
+ system_prompt:$('rpce-prompt').value.trim()||null, voice_stack_id:$('rpce-stack').value||null,
1074
+ motors:motors, nx:parseFloat($('rpce-nx').value), ny:parseFloat($('rpce-ny').value), img:$('rpce-img').value.trim()||null
1075
+ };
1076
+ rpAction('PUT','/robopark/api/character-presets/'+encodeURIComponent(editingCharPresetId),body).then(function(){ showOut('character preset saved'); $('rp-char-edit').style.display='none'; editingCharPresetId=null; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('save character preset: '+e.message); });
1077
+ }
1078
+ var cl=$('rp-char-list'); if(cl) cl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id) openCharPresetEdit(id); });
1079
+ var cpSave=$('rpce-save'); if(cpSave) cpSave.addEventListener('click', saveCharPresetEdit);
1080
+ var cpCancel=$('rpce-cancel'); if(cpCancel) cpCancel.addEventListener('click', function(){ $('rp-char-edit').style.display='none'; editingCharPresetId=null; });
1081
+
958
1082
  function renderRobotCardSelect(name, current){
959
1083
  var sel=$('rp-char-'+name); if(!sel) return;
960
1084
  sel.dataset.current=current||'';
@@ -1428,7 +1552,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1428
1552
  var token=(j&&j.enrollment_token)||'';
1429
1553
  $('pk-setup-token').value=token;
1430
1554
  $('pk-setup-result').style.display='block';
1431
- showOut('robot added: '+esc((j&&j.name)||name)+'\ntoken: '+esc(token));
1555
+ showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));
1432
1556
  pollRoboparkQuiet();
1433
1557
  }).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });
1434
1558
  }
@@ -4,5 +4,8 @@ import { type ExplicitContextOpts } from './profile.js';
4
4
  export interface AddRobotOptions extends ExplicitContextOpts {
5
5
  name: string;
6
6
  start?: boolean;
7
+ /** Force a specific park-map pad/character preset id instead of the
8
+ * fuzzy name-based match below — e.g. `--character jaguar`. */
9
+ character?: string;
7
10
  }
8
11
  export declare function roboparkAddRobot(config: Conf<InfinicodeConfig>, opts: AddRobotOptions): Promise<void>;
@@ -81,9 +81,12 @@ export async function roboparkAddRobot(config, opts) {
81
81
  console.log(` scheduler: ${chalk.cyan(ctx.schedulerUrl)}`);
82
82
  console.log(` name: ${chalk.cyan(opts.name)}${ctx.site ? chalk.dim(' @ ' + ctx.site) : ''}`);
83
83
  console.log();
84
- const characterId = await pickCharacterPreset(ctx.schedulerUrl, opts.name);
84
+ const characterId = opts.character?.trim() || await pickCharacterPreset(ctx.schedulerUrl, opts.name);
85
85
  if (characterId) {
86
- console.log(` character: ${chalk.cyan(characterId)}`);
86
+ console.log(` character: ${chalk.cyan(characterId)}${opts.character ? chalk.dim(' (forced via --character)') : chalk.dim(' (auto-matched)')}`);
87
+ }
88
+ else {
89
+ console.log(chalk.yellow(` ⚠ no character preset matched '${opts.name}' — pass --character <id> to bind this robot to a specific park-map pad`));
87
90
  }
88
91
  let minted;
89
92
  try {
@@ -129,7 +132,10 @@ export async function roboparkAddRobot(config, opts) {
129
132
  argv.push('--scheduler-port', schedulerPort);
130
133
  }
131
134
  argv.push('--enrollment-token', minted.enrollment_token, '--start', '--auto-start');
132
- console.log(chalk.dim(' run this ON THE ROBOT:'));
135
+ console.log(chalk.dim(` run this ON THE ROBOT${characterId ? ` (binds to pad: ${characterId})` : ''}:`));
133
136
  console.log(' ' + chalk.cyan(commandString(argv)));
137
+ if (!ctx.hubUrl) {
138
+ console.log(chalk.yellow(' ⚠ no hub URL resolved — the command above omits --hub-url; run `robopark hub-use` first or pass --hub-url explicitly if the robot needs it.'));
139
+ }
134
140
  console.log();
135
141
  }
@@ -226,6 +226,7 @@ program
226
226
  .option('--hub-url <url>', 'hub federation URL (defaults to saved hub profile / auto-discovery)')
227
227
  .option('--token <token>', 'shared mesh token (defaults to saved hub profile / auto-discovery)')
228
228
  .option('--scheduler-url <url>', 'scheduler base URL (defaults to saved hub profile / auto-discovery)')
229
+ .option('--character <id>', 'force a specific park-map pad/character preset id (default: fuzzy-matched from the robot name)')
229
230
  .option('--start', 'start the robot node on this machine now, instead of printing the one-liner')
230
231
  .action(async (name, opts) => {
231
232
  await roboparkAddRobot(config, { name, ...opts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.30",
3
+ "version": "2.8.32",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -424,6 +424,22 @@ async def init_db():
424
424
  pass
425
425
  await db.commit()
426
426
 
427
+ # ---- voice_stacks: options ROBOVOICE actually reads at runtime but
428
+ # this table didn't expose yet (llm auth + sampling + wake-word tuning) ----
429
+ for _table, _column, _coldef in (
430
+ ("voice_stacks", "llm_api_key", "TEXT"),
431
+ ("voice_stacks", "temperature", "REAL DEFAULT 0.7"),
432
+ ("voice_stacks", "num_ctx", "INTEGER DEFAULT 8192"),
433
+ ("voice_stacks", "wake_word_model", "TEXT"),
434
+ ("voice_stacks", "wake_word_threshold", "REAL DEFAULT 0.5"),
435
+ ("voice_stacks", "wake_word_timeout", "REAL DEFAULT 3.0"),
436
+ ):
437
+ try:
438
+ await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
439
+ except Exception:
440
+ pass
441
+ await db.commit()
442
+
427
443
  # No demo robots are seeded. Actual robots enroll via the device
428
444
  # enrollment API or are added through the scheduler UI. This keeps the
429
445
  # fleet view honest — only real connected hardware appears.
@@ -2272,6 +2288,9 @@ class VoiceStack(BaseModel):
2272
2288
  stt_language: str = "en"
2273
2289
  llm_provider: str = "ollama"
2274
2290
  llm_model: str = "gemma4:e4b"
2291
+ llm_api_key: Optional[str] = None
2292
+ temperature: float = 0.7
2293
+ num_ctx: int = 8192
2275
2294
  tts_provider: str = "elevenlabs"
2276
2295
  tts_voice: str = "21m00Tcm4TlvDq8ikWAM"
2277
2296
  tts_language: str = "en"
@@ -2279,6 +2298,9 @@ class VoiceStack(BaseModel):
2279
2298
  min_endpointing_delay: float = 0.7
2280
2299
  max_turns: int = 20
2281
2300
  wake_word_enabled: bool = False
2301
+ wake_word_model: Optional[str] = None
2302
+ wake_word_threshold: float = 0.5
2303
+ wake_word_timeout: float = 3.0
2282
2304
  created_at: Optional[datetime] = None
2283
2305
  updated_at: Optional[datetime] = None
2284
2306
 
@@ -2290,6 +2312,9 @@ class VoiceStackCreate(BaseModel):
2290
2312
  stt_language: str = "en"
2291
2313
  llm_provider: str = "ollama"
2292
2314
  llm_model: str = "gemma4:e4b"
2315
+ llm_api_key: Optional[str] = None
2316
+ temperature: float = 0.7
2317
+ num_ctx: int = 8192
2293
2318
  tts_provider: str = "elevenlabs"
2294
2319
  tts_voice: str = "21m00Tcm4TlvDq8ikWAM"
2295
2320
  tts_language: str = "en"
@@ -2297,6 +2322,9 @@ class VoiceStackCreate(BaseModel):
2297
2322
  min_endpointing_delay: float = 0.7
2298
2323
  max_turns: int = 20
2299
2324
  wake_word_enabled: bool = False
2325
+ wake_word_model: Optional[str] = None
2326
+ wake_word_threshold: float = 0.5
2327
+ wake_word_timeout: float = 3.0
2300
2328
 
2301
2329
  # Effective configuration returned to the ROBOVOICE agent for a given room/robot.
2302
2330
  class RobotVoiceConfig(BaseModel):
@@ -2493,14 +2521,20 @@ async def create_voice_stack(payload: VoiceStackCreate):
2493
2521
  await db.execute(
2494
2522
  """INSERT INTO voice_stacks
2495
2523
  (id, name, stt_provider, stt_model, stt_language, llm_provider, llm_model,
2524
+ llm_api_key, temperature, num_ctx,
2496
2525
  tts_provider, tts_voice, tts_language, allow_interruptions,
2497
- min_endpointing_delay, max_turns, wake_word_enabled, created_at, updated_at)
2498
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
2526
+ min_endpointing_delay, max_turns, wake_word_enabled,
2527
+ wake_word_model, wake_word_threshold, wake_word_timeout,
2528
+ created_at, updated_at)
2529
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
2499
2530
  (
2500
2531
  stack_id, payload.name, payload.stt_provider, payload.stt_model, payload.stt_language,
2501
- payload.llm_provider, payload.llm_model, payload.tts_provider, payload.tts_voice,
2532
+ payload.llm_provider, payload.llm_model, payload.llm_api_key, payload.temperature, payload.num_ctx,
2533
+ payload.tts_provider, payload.tts_voice,
2502
2534
  payload.tts_language, int(payload.allow_interruptions), payload.min_endpointing_delay,
2503
- payload.max_turns, int(payload.wake_word_enabled), now, now,
2535
+ payload.max_turns, int(payload.wake_word_enabled),
2536
+ payload.wake_word_model, payload.wake_word_threshold, payload.wake_word_timeout,
2537
+ now, now,
2504
2538
  ),
2505
2539
  )
2506
2540
  await db.commit()
@@ -2532,15 +2566,20 @@ async def update_voice_stack(stack_id: str, payload: VoiceStackCreate):
2532
2566
  await db.execute(
2533
2567
  """UPDATE voice_stacks SET
2534
2568
  name = ?, stt_provider = ?, stt_model = ?, stt_language = ?, llm_provider = ?,
2535
- llm_model = ?, tts_provider = ?, tts_voice = ?, tts_language = ?,
2569
+ llm_model = ?, llm_api_key = ?, temperature = ?, num_ctx = ?,
2570
+ tts_provider = ?, tts_voice = ?, tts_language = ?,
2536
2571
  allow_interruptions = ?, min_endpointing_delay = ?, max_turns = ?,
2537
- wake_word_enabled = ?, updated_at = ?
2572
+ wake_word_enabled = ?, wake_word_model = ?, wake_word_threshold = ?, wake_word_timeout = ?,
2573
+ updated_at = ?
2538
2574
  WHERE id = ?""",
2539
2575
  (
2540
2576
  payload.name, payload.stt_provider, payload.stt_model, payload.stt_language,
2541
- payload.llm_provider, payload.llm_model, payload.tts_provider, payload.tts_voice,
2577
+ payload.llm_provider, payload.llm_model, payload.llm_api_key, payload.temperature, payload.num_ctx,
2578
+ payload.tts_provider, payload.tts_voice,
2542
2579
  payload.tts_language, int(payload.allow_interruptions), payload.min_endpointing_delay,
2543
- payload.max_turns, int(payload.wake_word_enabled), now, stack_id,
2580
+ payload.max_turns, int(payload.wake_word_enabled),
2581
+ payload.wake_word_model, payload.wake_word_threshold, payload.wake_word_timeout,
2582
+ now, stack_id,
2544
2583
  ),
2545
2584
  )
2546
2585
  await db.commit()