Sanadv3/dashboard/static/index.html

3762 lines
235 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sanad Dashboard</title>
<style>
:root{--bg:#0a0f1a;--panel:#111827;--panel2:#1a2332;--accent:#0ea5e9;--accent2:#6366f1;--text:#e2e8f0;--muted:#64748b;--dim:#475569;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;--border:#1e293b;--glow:0 0 20px rgba(14,165,233,.08);--radius:12px;--err:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
/* Header */
header{background:linear-gradient(135deg,#111827 0%,#1a2332 100%);padding:.7rem 1.5rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;backdrop-filter:blur(12px)}
header h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em} header h1 span{background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr-right{display:flex;align-items:center;gap:.8rem;font-size:.78rem}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.dot-ok{background:var(--success);box-shadow:0 0 6px var(--success)} .dot-err{background:var(--danger);box-shadow:0 0 6px var(--danger)} .dot-warn{background:var(--warn)}
.hdr-badge{padding:2px 7px;border-radius:4px;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.hdr-badge-err{background:rgba(239,68,68,.15);color:var(--danger);border:1px solid rgba(239,68,68,.3)}
.hdr-badge-ok{background:rgba(34,197,94,.12);color:var(--success);border:1px solid rgba(34,197,94,.25)}
/* N2 — global subsystem status strip + Controller tab */
.status-pills{display:flex;gap:.45rem;align-items:center;padding:.3rem 1.5rem;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap}
.pill-off{background:rgba(100,116,139,.12);color:var(--muted);border:1px solid var(--border)}
.pill-on{background:rgba(34,197,94,.14);color:var(--success);border:1px solid rgba(34,197,94,.3)}
.pill-soon{opacity:.45;cursor:not-allowed;background:rgba(100,116,139,.08);color:var(--muted);border:1px dashed var(--border)}
.steppad{display:grid;grid-template-columns:repeat(3,1fr);gap:.3rem;max-width:230px}
.steppad button{font-size:1rem;padding:.5rem 0}
.ctrl-strip{padding-left:0;border:none;background:transparent}
.motion-locked{opacity:.5;pointer-events:none;filter:grayscale(.4)}
#motion-lock-banner{display:none;align-items:center;gap:.5rem;margin-bottom:.6rem;padding:.5rem .7rem;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.35);border-radius:8px;color:var(--warn);font-size:.74rem}
#estop{background:var(--danger);color:#fff;border:none;padding:.35rem .9rem;border-radius:6px;font-weight:700;font-size:.75rem;cursor:pointer;letter-spacing:.03em;box-shadow:0 0 12px rgba(239,68,68,.3);transition:all .15s}
#estop:hover{box-shadow:0 0 20px rgba(239,68,68,.5);transform:scale(1.04)}
/* Tabs */
.tabs{display:flex;gap:0;background:var(--panel);border-bottom:1px solid var(--border);padding:0 1.5rem;overflow-x:auto}
.tab{padding:.55rem 1.1rem;font-size:.78rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap;font-weight:500}
.tab:hover{color:var(--text)} .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none;padding:1rem 1.5rem} .tab-content.active{display:block}
/* Grid */
.grid{display:grid;grid-template-columns:1fr 1fr;gap:.8rem}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
/* Cards */
.card{background:var(--panel);border-radius:var(--radius);padding:1rem 1.1rem;border:1px solid var(--border);box-shadow:var(--glow);transition:border-color .2s}
.card:hover{border-color:rgba(14,165,233,.2)}
.card h3{font-size:.82rem;color:var(--accent);margin-bottom:.6rem;display:flex;align-items:center;gap:.4rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.card h3 svg{width:14px;height:14px;opacity:.7}
.card-full{grid-column:1/-1}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
.btn:disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)} .btn-primary:hover{opacity:.85}
.btn-danger{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)} .btn-danger:hover{background:rgba(239,68,68,.2)}
.btn-ghost{background:transparent;color:var(--muted);border-color:var(--border)} .btn-ghost:hover{color:var(--text);border-color:var(--dim)}
.btn-success{background:rgba(34,197,94,.12);color:var(--success);border-color:rgba(34,197,94,.3)} .btn-success:hover{background:rgba(34,197,94,.2)}
.btn-sm{padding:.25rem .5rem;font-size:.7rem}
.btn.loading{pointer-events:none;opacity:.6} .btn.loading::after{content:'';width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .5s linear infinite;margin-left:.3rem}
@keyframes spin{to{transform:rotate(360deg)}}
/* Form */
input,textarea,select{background:var(--bg);color:var(--text);border:1px solid var(--border);padding:.4rem .6rem;border-radius:6px;width:100%;font-size:.8rem;font-family:inherit;transition:border-color .15s}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:50px}
label{font-size:.72rem;color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:.04em}
.row{display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem}
/* Badge */
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:600}
.badge-ok{background:rgba(34,197,94,.12);color:var(--success)} .badge-err{background:rgba(239,68,68,.12);color:var(--danger)} .badge-warn{background:rgba(245,158,11,.12);color:var(--warn)} .badge-info{background:rgba(99,102,241,.12);color:var(--accent2)}
/* Table */
table{width:100%;border-collapse:collapse;font-size:.76rem}
th{color:var(--muted);font-weight:600;text-transform:uppercase;font-size:.68rem;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--border)}
td{padding:5px 8px;border-bottom:1px solid rgba(30,41,55,.5)}
tr:hover td{background:rgba(14,165,233,.03)}
/* Action buttons grid */
.action-btn{background:var(--panel2);color:var(--text);border:1px solid var(--border);padding:.35rem .6rem;border-radius:6px;cursor:pointer;font-size:.72rem;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.action-btn:hover{background:var(--accent);border-color:var(--accent);color:#fff}
.action-btn.running{background:var(--accent);color:#fff;animation:pulse 1s infinite;pointer-events:none}
.action-btn:disabled{opacity:.3;cursor:not-allowed}
.type-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0} .type-sdk{background:#a78bfa} .type-jsonl{background:var(--success)}
.action-list{max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;background:var(--panel2)}
.action-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:.78rem;user-select:none;transition:background .1s}
.action-row:last-child{border-bottom:none}
.action-row:hover{background:rgba(255,255,255,.04)}
.action-row.selected{background:var(--accent);color:#fff}
.action-row.running{background:var(--accent);color:#fff;animation:pulse 1s infinite}
.action-row .r-name{flex:1;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.action-row .r-meta{color:var(--dim);font-size:.7rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.action-row.selected .r-meta{color:rgba(255,255,255,.85)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Mute */
.mute-btn{min-width:80px;text-align:center;padding:.35rem .6rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid var(--border);transition:all .15s}
.mute-btn.off{background:rgba(34,197,94,.1);color:var(--success);border-color:rgba(34,197,94,.25)}
.mute-btn.on{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)}
/* Gallery */
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.5rem;max-height:220px;overflow-y:auto;padding:.2rem}
.gallery-grid img{width:100%;height:85px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid var(--border);transition:all .15s}
.gallery-grid img:hover{border-color:var(--accent);transform:scale(1.03)}
/* Log box */
.log-box{background:#000;color:#4ade80;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.7rem;padding:.6rem;border-radius:8px;overflow-y:auto;white-space:pre-wrap;line-height:1.4;border:1px solid var(--border)}
/* Toast */
#toast-box{position:fixed;top:4rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:.4rem}
.toast{padding:.55rem 1rem;border-radius:8px;font-size:.78rem;color:#fff;animation:slideIn .25s;max-width:360px;word-break:break-word;backdrop-filter:blur(8px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.toast-ok{background:rgba(22,101,52,.92)} .toast-err{background:rgba(153,27,27,.92)} .toast-info{background:rgba(30,64,175,.92)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Empty state */
.empty{color:var(--dim);font-size:.78rem;text-align:center;padding:1.5rem;font-style:italic}
/* Toggle switch */
.switch{position:relative;width:36px;height:20px;display:inline-block}
.switch input{opacity:0;width:0;height:0}
.switch .slider{position:absolute;inset:0;background:var(--dim);border-radius:10px;cursor:pointer;transition:.2s}
.switch .slider::before{content:'';position:absolute;width:16px;height:16px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:.2s}
.switch input:checked+.slider{background:var(--accent)}
.switch input:checked+.slider::before{transform:translateX(16px)}
/* Scrollbar */
::-webkit-scrollbar{width:6px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:var(--dim);border-radius:3px} ::-webkit-scrollbar-thumb:hover{background:var(--muted)}
/* WhatsApp-style voice-message record cards */
.rec-card{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem;margin-bottom:.55rem;background:rgba(255,255,255,.025);border:1px solid var(--border);border-radius:.7rem;transition:background .15s,border-color .15s}
.rec-card.is-playing{background:rgba(52,211,153,.07);border-color:rgba(52,211,153,.4)}
.rec-card .rec-row{display:flex;align-items:center;gap:.6rem}
.rec-sel{width:16px;height:16px;flex:0 0 auto;cursor:pointer}
.rec-play{flex:0 0 auto;width:38px;height:38px;border-radius:50%;border:none;background:var(--accent);color:#04121f;font-size:1rem;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .1s,background .15s}
.rec-play:hover{transform:scale(1.06)}
.rec-card.is-playing .rec-play{background:#34d399}
.rec-replay{flex:0 0 auto;width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.95rem;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:color .12s,border-color .12s}
.rec-replay:hover{color:#34d399;border-color:#34d399}
.rec-wave{flex:1;min-width:70px;display:flex;align-items:center;gap:2px;height:30px;overflow:hidden;cursor:pointer;touch-action:none}
.rec-wave .wb{flex:1 1 0;min-width:2px;max-width:5px;border-radius:2px;background:rgba(255,255,255,.16);transition:background .12s,height .12s;pointer-events:none}
.rec-wave .wb.played{background:#34d399}
.rec-time{flex:0 0 auto;font-size:.7rem;color:var(--dim);font-variant-numeric:tabular-nums;min-width:74px;text-align:right}
.rec-card.is-playing .rec-time{color:#34d399}
.rec-name{font-size:.74rem;font-weight:600;color:var(--muted)}
.rec-text{font-size:.8rem;line-height:1.45;color:var(--fg,#dfe7ef);white-space:pre-wrap;word-break:break-word}
.rec-acts{flex:0 0 auto;display:flex;gap:.3rem;align-items:center}
/* Battery widget (Temperature tab) */
.batt-icon{position:relative;width:56px;height:27px;border:2px solid var(--muted);border-radius:5px;padding:2px;flex:0 0 auto}
.batt-icon::after{content:'';position:absolute;right:-6px;top:7px;width:4px;height:9px;background:var(--muted);border-radius:0 2px 2px 0}
.batt-fill{height:100%;width:0%;border-radius:2px;background:var(--success);transition:width .45s ease,background .3s}
.batt-fill.charging{animation:battpulse 1.3s ease-in-out infinite}
@keyframes battpulse{0%,100%{opacity:1}50%{opacity:.55}}
/* ── Tablet (single column already kicks in at 900px for .grid) ── */
@media (max-width:768px){
header{padding:.6rem .9rem}
.tab-content{padding:.85rem .85rem}
.status-pills{padding:.3rem .9rem}
.tabs{padding:0 .8rem}
}
/* ── Phone view ──────────────────────────────────────────────── */
@media (max-width:640px){
/* Header wraps gracefully; controls drop to a second line if needed */
header{flex-wrap:wrap;gap:.4rem;padding:.5rem .7rem}
header h1{font-size:1.05rem}
.hdr-right{gap:.4rem;font-size:.68rem;flex-wrap:wrap;justify-content:flex-end;width:auto}
#estop{padding:.28rem .6rem;font-size:.66rem}
.hdr-badge{font-size:.6rem;padding:2px 5px}
/* Reclaim horizontal space on the narrow viewport */
.status-pills{padding:.3rem .55rem;gap:.28rem}
.tabs{padding:0 .45rem;-webkit-overflow-scrolling:touch}
.tab{padding:.5rem .65rem;font-size:.74rem}
.tab-content{padding:.7rem .55rem}
.grid{gap:.55rem}
.card{padding:.8rem .7rem}
.card-full,.card{min-width:0}
/* Wide tables scroll sideways instead of stretching the page */
.card table{display:block;max-width:100%;overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
/* Generic control rows wrap rather than overflow */
.row{flex-wrap:wrap}
.toast{max-width:calc(100vw - 24px)}
/* Voice-message record cards: larger touch targets, acts wrap below */
.rec-card{padding:.55rem .6rem}
.rec-card .rec-row{flex-wrap:wrap;gap:.45rem}
.rec-play{width:44px;height:44px;font-size:1.15rem}
.rec-replay{width:34px;height:34px;font-size:1.05rem}
.rec-wave{flex:1 1 110px;min-width:60px;height:34px}
.rec-time{min-width:0;font-size:.72rem;flex:0 0 auto}
.rec-acts{margin-left:auto}
.rec-text{font-size:.82rem}
}
/* ── Small phone ─────────────────────────────────────────────── */
@media (max-width:380px){
header h1{font-size:.95rem}
.tab{padding:.45rem .55rem;font-size:.72rem}
.rec-card .rec-sel{order:-1}
.rec-wave{flex:1 1 100%;order:5}
.rec-time{order:4}
}
/* ── Mobile audit pass 2 — per-tab phone fixes ───────────────── */
@media (max-width:768px){
#livemapFrame{height:62vh} #temp3d-frame{height:60vh} #medWrap{max-height:60vh}
}
@media (max-width:640px){
/* Operations · Audio Control: fixed-width mute buttons + 5-item profile row */
.mute-btn{min-width:unset;width:100%;display:block}
#audio-profile{flex:1 1 100%} #audio-profile~.btn{flex:1 1 auto}
#action-speed{width:auto;flex:0 0 auto}
/* Voice tab: nested gaps, packed subprocess row, key-input labels */
#tab-voice .row[style*="gap:1.2rem"]{gap:.6rem}
#tab-voice .btn.btn-sm.btn-ghost{min-width:unset;flex:0 1 auto}
#tab-voice label[style*="min-width:70px"]{min-width:unset;display:block;margin-bottom:.3rem}
/* Motion & Replay: two-column min-width panes + fixed-width selects */
#tab-motion .card > div[style*="min-width:260px"]{min-width:0}
#tab-motion #action-speed-2,#tab-motion #replay-speed{width:55px}
#tab-motion #combo-speed{width:60px}
#tab-motion #teach-duration{width:auto;flex:0 0 45px}
/* Navigation control bar → stack vertically, hide meta spans */
#tab-navigation .card-full:has(#navMapSelect) > div:first-child{flex-direction:column;align-items:stretch}
#tab-navigation .card-full:has(#navMapSelect) > div:first-child > h3,
#tab-navigation .card-full:has(#navMapSelect) > div:first-child > select,
#tab-navigation .card-full:has(#navMapSelect) > div:first-child > .action-btn{width:100%}
#tab-navigation .card-full:has(#navMapSelect) > div:first-child > span{display:none}
/* Map Editor control bar → stack */
#tab-mapeditor .card-full > div:first-child{flex-direction:column;align-items:stretch}
#tab-mapeditor .card-full .action-btn{flex:1 1 auto;min-width:auto}
#medMapSelect,#medBrush{width:100%}
/* Embeds shrink so they don't eat the phone viewport (but the Temperature
3D viewer IS the tab's main content → keep it tall enough for the model
+ its compact overlay panels). */
#livemapFrame{height:50vh} #temp3d-frame{height:72vh;min-height:360px} #medWrap{max-height:40vh}
/* Recognition gallery: smaller tiles */
.gallery-grid{grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:.3rem}
.gallery-grid img{height:60px}
/* Mask: slightly larger color swatches (touch) + flexible number inputs */
#tab-mask input[type="color"]{width:50px} #mask-bright-val{width:auto}
#mask-img-id,#mask-anim-id{width:50px}
/* Temperature battery widget gaps */
#battery-card .row{gap:.6rem}
/* Terminal: wrap header, shorter min-height */
#tab-terminal .row{flex-wrap:wrap} #tab-terminal h3{font-size:.85rem} #tab-terminal .card{min-height:240px}
/* Recordings bulk-delete label fits */
#rec-del-selected{white-space:nowrap;font-size:.65rem;padding:.2rem .35rem}
/* Settings: shorter log box + compact header buttons */
#log-box{height:150px}
#tab-settings .card-full > .row button{font-size:.65rem;padding:.2rem .3rem;white-space:nowrap}
}
</style>
</head>
<body>
<div id="toast-box"></div>
<!-- Header -->
<header>
<h1><span>Sanad</span> Dashboard</h1>
<div class="hdr-right">
<span id="mic-badge" class="hdr-badge hdr-badge-err" style="display:none">MIC OFF</span>
<span id="spk-badge" class="hdr-badge hdr-badge-err" style="display:none">SPK OFF</span>
<span id="gemini-badge" class="hdr-badge" style="display:none"></span>
<span id="arm-hdr-badge" class="hdr-badge" style="display:none"></span>
<span class="dot" id="status-dot"></span>
<span id="status-text" style="font-size:.78rem">Connecting...</span>
<button id="estop" onclick="emergencyStop()">E-STOP</button>
</div>
</header>
<!-- N2 — global subsystem status strip (visible on every tab) -->
<div id="status-pills" class="status-pills">
<span class="hdr-badge pill-off" id="pill-camera" title="Camera / vision">CAM</span>
<span class="hdr-badge pill-off" id="pill-face" title="Face recognition">FACE</span>
<span class="hdr-badge pill-off" id="pill-place" title="Place / zone recognition">PLACE</span>
<span class="hdr-badge pill-off" id="pill-movement" title="Movement (manual locomotion armed)">MOVE</span>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab active" onclick="switchTab('operations')">Operations</div>
<div class="tab" onclick="switchTab('voice')">Voice & Audio</div>
<div class="tab" onclick="switchTab('motion')">Motion & Replay</div>
<div class="tab" onclick="switchTab('controller')">Controller</div>
<div class="tab" onclick="switchTab('navigation')">Navigation</div>
<div class="tab" onclick="switchTab('livemap')">Live Map</div>
<div class="tab" onclick="switchTab('mapeditor')">Map Editor</div>
<div class="tab" onclick="switchTab('recognition')">Recognition</div>
<div class="tab" onclick="switchTab('mask')">Mask Face</div>
<div class="tab" onclick="switchTab('recordings')">Recordings</div>
<div class="tab" onclick="switchTab('temp')">Temperature</div>
<div class="tab" onclick="switchTab('terminal')">Terminal</div>
<div class="tab" onclick="switchTab('settings')">Settings & Logs</div>
</div>
<!-- ==================== TAB: Operations ==================== -->
<div class="tab-content active" id="tab-operations">
<div class="grid">
<!-- System Info -->
<div class="card" id="system-info-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>System Info</h3>
<div id="sys-summary" style="font-size:.78rem;line-height:1.5">
<div class="empty">Loading...</div>
</div>
<!-- Live stats: storage / battery / motor temp (refreshSysLive, ~8s). -->
<div id="sys-live" style="font-size:.78rem;line-height:1.55;margin-top:.4rem;border-top:1px solid var(--border);padding-top:.4rem"></div>
<details style="margin-top:.4rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Network interfaces</summary>
<div id="sys-network" style="font-size:.7rem;margin-top:.3rem"></div>
</details>
<details style="margin-top:.4rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Subsystems (connected / disconnected)</summary>
<div id="sys-subsystems" style="font-size:.7rem;margin-top:.3rem"></div>
</details>
</div>
<!-- Audio Control -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>Audio Control</h3>
<div class="row" style="justify-content:space-between">
<div><label>Microphone</label><br><button id="mic-btn" class="mute-btn off" onclick="toggleMic()">Unmuted</button></div>
<div><label>Speaker</label><br><button id="spk-btn" class="mute-btn off" onclick="toggleSpeaker()">Unmuted</button></div>
</div>
<div style="margin-top:.7rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<label>Speaker Volume (G1 / JBL / Anker)</label>
<span id="g1-vol-label" style="font-size:.72rem;color:var(--dim)"></span>
</div>
<div class="row" style="margin-top:.25rem;gap:.3rem;align-items:center">
<button class="btn btn-ghost btn-sm" onclick="setG1Vol(0,this)" title="Mute">0</button>
<input id="g1-vol-slider" type="range" min="0" max="100" step="5" value="100"
oninput="document.getElementById('g1-vol-label').textContent=this.value+'%'"
onchange="setG1Vol(parseInt(this.value),this)"
style="flex:1">
<button class="btn btn-ghost btn-sm" onclick="setG1Vol(100,this)" title="Full">100</button>
</div>
<div id="g1-vol-status" style="font-size:.65rem;color:var(--dim);margin-top:.2rem">
Controls the ACTIVE speaker — G1 chest (DDS) + the selected PulseAudio sink (JBL/Anker). Applies live.
</div>
</div>
<div style="margin-top:.6rem">
<label>Audio device profile</label>
<div class="row" style="margin-top:.3rem">
<select id="audio-profile" style="flex:1" onchange="selectAudioProfile(this.value)">
<option value="">Loading profiles...</option>
</select>
<button class="btn btn-primary btn-sm" onclick="applyAudioProfile(this)" title="Apply selected profile to PulseAudio">Apply</button>
<button class="btn btn-ghost btn-sm" onclick="scanAudioDevices(this)" title="Scan all USB ports for audio devices">Scan</button>
<button class="btn btn-ghost btn-sm" onclick="resetAudioSubsystem(this)" title="SOFT reset: restart PulseAudio/pipewire-pulse. Use when devices look stuck. Does NOT recover a missing USB mic — for that use USB Reset.">Reset PA</button>
<button class="btn btn-ghost btn-sm" onclick="usbResetAnker(this)" title="HARD reset: unbind+rebind snd-usb-audio for the Anker (VID:PID 291a:3301). Use when Anker is plugged but the mic profile is missing from pactl. Needs a one-time sudoers setup — see hint in the error toast if it fails." style="color:var(--warn,#f5a623)">USB Reset</button>
</div>
<div id="audio-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
</div>
<details style="margin-top:.5rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Manual sink / source override</summary>
<div class="row" style="margin-top:.3rem">
<select id="audio-sink" style="flex:1"></select>
</div>
<div class="row" style="margin-top:.3rem">
<select id="audio-source" style="flex:1"></select>
</div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="applyManualAudio(this)">Apply</button>
</div>
</details>
<div style="margin-top:.5rem;font-size:.72rem;color:var(--dim)" id="audio-status-text"></div>
</div>
<!-- Motion Quick Panel -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>Quick Actions</h3>
<div class="row">
<label>Speed</label>
<select id="action-speed" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<span id="arm-busy-badge" class="badge badge-err" style="display:none">BUSY</span>
<button class="btn btn-danger btn-sm" onclick="cancelAction()" style="margin-left:auto">Cancel</button>
</div>
<div id="running-action" style="font-size:.75rem;color:var(--accent);margin-bottom:.3rem;display:none"></div>
<div id="sdk-actions" style="display:flex;flex-wrap:wrap;gap:3px;margin-top:.2rem"></div>
<div id="jsonl-actions" style="display:flex;flex-wrap:wrap;gap:3px;margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- ==================== TAB: Voice & Audio ==================== -->
<div class="tab-content" id="tab-voice">
<div class="grid">
<!-- Live Voice Commands -->
<div class="card">
<h3>Live Voice Commands</h3>
<div class="row">
<button class="btn btn-success" onclick="startLiveVoice(this)">Start</button>
<button class="btn btn-danger" onclick="stopLiveVoice(this)">Stop</button>
<span id="lv-state" class="badge"></span>
<button class="btn btn-sm mic-mute-shortcut btn-success" onclick="toggleMic()" style="margin-left:auto">Mic: LIVE</button>
<button class="btn btn-sm spk-mute-shortcut btn-success" onclick="toggleSpeaker()">Speaker: LIVE</button>
</div>
<div class="row" style="margin-top:.4rem;gap:1.2rem">
<div class="row" style="gap:.4rem">
<label>Arm Trigger</label>
<label class="switch" title="Master gate — when OFF, voice never moves the arm"><input type="checkbox" id="lv-trigger-enabled" onchange="setTriggerEnabled(this.checked)"><span class="slider"></span></label>
</div>
<div class="row" style="gap:.4rem">
<label>Deferred Trigger</label>
<label class="switch" title="When ON, arm fires ~0.6s after you stop talking"><input type="checkbox" id="lv-deferred" onchange="setDeferredMode(this.checked)"><span class="slider"></span></label>
</div>
</div>
<div style="margin-top:.4rem;font-size:.72rem;color:var(--muted)">
<div>Last heard: <strong id="lv-last-text">--</strong></div>
<div>Pending action: <strong id="lv-pending">--</strong></div>
<div>Audio attached: <strong id="lv-audio">--</strong> | Arm attached: <strong id="lv-arm">--</strong> | Gemini: <strong id="lv-gem">--</strong></div>
<div id="lv-error" style="color:#f55;margin-top:.2rem"></div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:.3rem;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="copyLogBox('lv-transcript',this)" title="Copy transcript to clipboard">Copy</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('lv-transcript').textContent=''" title="Clear transcript">Clear</button>
</div>
<div id="lv-transcript" class="log-box" style="height:90px;margin-top:.3rem"></div>
</div>
<!-- Live Gemini Subprocess -->
<div class="card">
<h3>Live Gemini Process</h3>
<div class="row">
<button class="btn btn-success" onclick="startLiveSub(this)">Start</button>
<button class="btn btn-danger" onclick="stopLiveSub(this)">Stop</button>
<button id="ls-cam-btn" class="btn btn-sm btn-ghost" onclick="toggleGeminiCamera(this)" title="Stream camera frames to Gemini Live — same toggle as the Recognition tab">Camera: --</button>
<button id="ls-rec-btn" class="btn btn-sm btn-ghost" onclick="toggleAutoRecord(this)" title="Auto-save every conversation turn to data/recordings/. Toggling takes effect live — no session restart.">Rec: --</button>
<span id="ls-state" class="badge"></span>
<span id="ls-pausemode" class="badge" onclick="toggleLiveHoldBadge()" style="cursor:pointer" title="Pause mode (same as the Saved Records 'Keep Gemini paused' toggle). Auto = pause only during a record, resume after. Manual = Gemini stays paused until you switch back. Click to toggle.">Pause: Auto</span>
<button class="btn btn-sm mic-mute-shortcut btn-success" onclick="toggleMic()" style="margin-left:auto">Mic: LIVE</button>
<button class="btn btn-sm spk-mute-shortcut btn-success" onclick="toggleSpeaker()">Speaker: LIVE</button>
</div>
<div style="margin-top:.4rem;font-size:.72rem;color:var(--muted)">
<div>State: <strong id="ls-msg">--</strong></div>
<div>User: <strong id="ls-user">--</strong></div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:.3rem;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="copyLogBox('ls-log',this)" title="Copy the Live Gemini subprocess log tail">Copy</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('ls-log').textContent=''" title="Clear the log display (server-side buffer stays)">Clear</button>
</div>
<div id="ls-log" class="log-box" style="height:110px;margin-top:.3rem"></div>
</div>
<!-- Gemini API Key -->
<div class="card card-full">
<h3>Gemini API Key</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem">
The key used by <strong>GeminiVoiceClient</strong> and the <strong>Live Gemini subprocess</strong>.
Saved to <code>data/motions/config.json</code>. Get a free key at
<a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:var(--accent)">aistudio.google.com/app/apikey</a>.
</div>
<div class="row" style="align-items:center;gap:.4rem">
<label style="min-width:70px">Current</label>
<input id="gm-key-current" readonly style="flex:1;font-family:monospace;letter-spacing:1px" placeholder="(not loaded)">
<span id="gm-key-source" class="badge" style="font-size:.65rem"></span>
<button class="btn btn-ghost btn-sm" onclick="refreshApiKey(this)" title="Reload masked key from server">Refresh</button>
</div>
<div class="row" style="align-items:center;gap:.4rem;margin-top:.4rem">
<label style="min-width:70px">New key</label>
<input id="gm-key-new" type="password" placeholder="Paste new AIza... key here" style="flex:1;font-family:monospace" autocomplete="off">
<button class="btn btn-ghost btn-sm" onclick="toggleApiKeyVisibility()" title="Show/hide while typing">👁</button>
<button class="btn btn-primary btn-sm" onclick="saveApiKey(this)" title="Validate, save to config.json, hot-swap in memory">Save</button>
</div>
<div id="gm-key-msg" style="font-size:.7rem;margin-top:.3rem;color:var(--muted)"></div>
</div>
<!-- Typed Replay -->
<div class="card card-full">
<h3>Typed Replay Engine</h3>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<div style="flex:2;min-width:280px">
<textarea id="tr-text" placeholder="Type the sentence Gemini should speak exactly..." rows="2"></textarea>
<div class="row" style="margin-top:.3rem">
<label class="switch"><input type="checkbox" id="tr-capture" checked><span class="slider"></span></label>
<label style="margin-right:.5rem">Record speaker</label>
<input id="tr-name" placeholder="Record name (optional)" style="flex:1">
</div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary" onclick="trGenerate(this)">Generate & Play</button>
<button class="btn btn-ghost" onclick="trReplayLast(this)">Replay Last</button>
<button class="btn btn-success" onclick="trSaveLast(this)">Save Last</button>
</div>
</div>
<div style="flex:1;min-width:200px">
<label>Session</label>
<div id="tr-session" style="font-size:.72rem;color:var(--muted);margin-top:.3rem;line-height:1.6"></div>
</div>
</div>
</div>
<!-- Wake Phrases -->
<div class="card card-full">
<h3>Wake Phrase Manager</h3>
<div class="row"><select id="wp-action" style="flex:1" onchange="loadWakePhrases(this.value)"><option value="">-- select action --</option></select><button class="btn btn-ghost btn-sm" onclick="refreshWakeActions()">Refresh</button></div>
<div id="wp-phrases" style="max-height:140px;overflow-y:auto;font-size:.75rem;margin-top:.3rem"></div>
<div class="row" style="margin-top:.3rem">
<input id="wp-new" placeholder="New phrase (Arabic or English)" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="addWakePhrase()">Add</button>
</div>
</div>
</div>
</div>
<!-- ==================== TAB: Motion & Replay ==================== -->
<div class="tab-content" id="tab-motion">
<div id="motion-lock-banner">🔒 Arm actions are disabled while <b>movement</b> is enabled (Controller tab). Disable movement to replay / trigger / teach.</div>
<div class="grid" id="motion-grid">
<!-- Full Motion Control -->
<div class="card card-full">
<h3>Motion Control</h3>
<div class="row">
<label>Gestural Speaking</label>
<label class="switch"><input type="checkbox" id="gestural" onchange="toggleGestural(this.checked)"><span class="slider"></span></label>
<span style="margin-left:1rem"><label>Speed</label></span>
<select id="action-speed-2" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<span id="arm-busy-badge-2" class="badge badge-err" style="display:none">BUSY</span>
<button class="btn btn-danger btn-sm" onclick="cancelAction()" style="margin-left:auto">Cancel</button>
</div>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-top:.4rem">
<div style="flex:1;min-width:260px">
<div class="row" style="justify-content:space-between;align-items:center;margin:0 0 .3rem 0">
<label style="margin:0">SDK Actions (built-in)</label>
<button id="play-sdk-btn" class="btn btn-primary btn-sm" onclick="playSelectedAction('sdk')" disabled>Play</button>
</div>
<div id="sdk-actions-2" class="action-list"></div>
</div>
<div style="flex:1;min-width:260px">
<div class="row" style="justify-content:space-between;align-items:center;margin:0 0 .3rem 0">
<label style="margin:0">JSONL Replays (recorded)</label>
<button id="play-jsonl-btn" class="btn btn-primary btn-sm" onclick="playSelectedAction('jsonl')" disabled>Play</button>
</div>
<div id="jsonl-actions-2" class="action-list"></div>
</div>
</div>
</div>
<!-- Replay Manager -->
<div class="card card-full">
<h3>Replay Manager</h3>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<div style="flex:1;min-width:260px">
<label>Motion Files</label>
<div id="replay-files" style="max-height:200px;overflow-y:auto;margin-top:.3rem"></div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-ghost btn-sm" onclick="refreshReplayFiles()">Refresh</button>
<label style="cursor:pointer"><input type="file" accept=".jsonl" id="upload-jsonl" style="display:none" onchange="uploadMotionFile(this)"><span class="btn btn-success btn-sm" style="cursor:pointer">Upload .jsonl</span></label>
</div>
</div>
<div style="flex:1;min-width:260px">
<label>Test Replay</label>
<div class="row" style="margin-top:.3rem">
<input id="replay-name" placeholder="e.g. laugh.jsonl" style="flex:1">
<select id="replay-speed" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<button class="btn btn-primary btn-sm" onclick="testReplay(this)">Play</button>
<button class="btn btn-danger btn-sm" onclick="cancelReplay()">Cancel</button>
</div>
<label style="margin-top:.7rem;display:block">Teaching Mode</label>
<div class="row" style="margin-top:.3rem">
<input id="teach-name" placeholder="New motion name" style="flex:1">
<input id="teach-duration" type="number" value="15" min="3" max="120" style="width:55px" title="Duration in seconds">
<button class="btn btn-primary btn-sm" onclick="startTeaching(this)">Teach</button>
<button class="btn btn-danger btn-sm" onclick="stopTeaching(this)">Stop</button>
</div>
<div id="teach-status" style="font-size:.72rem;color:var(--muted);margin-top:.3rem"></div>
</div>
</div>
</div>
<!-- Macro Recorder -->
<div class="card card-full">
<h3>Macro Recorder (Audio + Motion)</h3>
<!-- Record -->
<label>Record</label>
<div class="row" style="margin-top:.3rem">
<input id="macro-name" placeholder="Macro name" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="startMacro(this)">Record</button>
<button class="btn btn-danger btn-sm" onclick="stopMacro(this)">Stop</button>
</div>
<!-- Play: pick a voice + motion (either optional), play in parallel -->
<label style="margin-top:.6rem;display:block">Play</label>
<div class="row" style="margin-top:.3rem;gap:.4rem;flex-wrap:wrap">
<div style="flex:1;min-width:200px">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Voice (WAV)</div>
<select id="combo-voice" style="width:100%"><option value="">— none —</option></select>
</div>
<div style="flex:1;min-width:200px">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Motion (JSONL)</div>
<select id="combo-motion" style="width:100%"><option value="">— none —</option></select>
</div>
<div style="align-self:flex-end">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Speed</div>
<select id="combo-speed" style="width:75px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
</div>
<div style="align-self:flex-end;display:flex;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="refreshCombo()" title="Reload file lists"></button>
<button class="btn btn-success btn-sm" onclick="playCombo(this)">Play</button>
<button class="btn btn-danger btn-sm" onclick="stopCombo(this)" title="Stop audio + return arm to home">Stop</button>
</div>
</div>
<div id="combo-status" style="font-size:.7rem;color:var(--muted);margin-top:.3rem"></div>
<div id="macro-status" style="font-size:.72rem;color:var(--muted);margin-top:.3rem"></div>
</div>
</div>
</div>
<!-- ==================== TAB: Recognition ==================== -->
<div class="tab-content" id="tab-recognition">
<div class="grid">
<!-- Status & Toggles -->
<div class="card card-full">
<h3>Camera Vision &amp; Face Recognition</h3>
<div class="row" style="gap:1rem;flex-wrap:wrap">
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Camera Vision</label>
<label class="switch">
<input type="checkbox" id="rec-vision-toggle" onchange="setVisionEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-camera-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Face Recognition</label>
<label class="switch">
<input type="checkbox" id="rec-facerec-toggle" onchange="setFaceRecEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-facerec-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<button class="btn btn-ghost btn-sm" onclick="syncGallery(this)" title="Re-send gallery to live Gemini session">↻ Sync Gallery</button>
</div>
<div style="margin-top:.4rem;font-size:.7rem;color:var(--dim)" id="rec-status-line">
Toggles take effect within ~1 second on the running Gemini session — no restart required.
</div>
</div>
<!-- Live Preview -->
<div class="card">
<h3>Live Preview</h3>
<div id="rec-preview-wrap" style="background:#000;border-radius:.4rem;overflow:hidden;text-align:center;min-height:180px;display:flex;align-items:center;justify-content:center">
<img id="rec-preview-img" src="" alt="" style="max-width:100%;display:none">
<div id="rec-preview-empty" style="color:var(--dim);font-size:.75rem;padding:1rem">Camera off — toggle Vision ON to see the live feed.</div>
</div>
<div style="margin-top:.3rem;font-size:.65rem;color:var(--dim)" id="rec-preview-meta">--</div>
<div style="margin-top:.45rem">
<div style="font-size:.7rem;color:var(--dim);margin-bottom:.2rem">Resolution / FPS</div>
<div class="row" style="gap:.25rem;flex-wrap:wrap" id="rec-res-buttons">
<button class="btn btn-ghost btn-sm" data-w="424" data-h="240" data-fps="15" onclick="setCameraMode(this)">424×240 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="424" data-h="240" data-fps="30" onclick="setCameraMode(this)">424×240 · 30</button>
<button class="btn btn-ghost btn-sm" data-w="640" data-h="480" data-fps="15" onclick="setCameraMode(this)">640×480 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="640" data-h="480" data-fps="30" onclick="setCameraMode(this)">640×480 · 30</button>
<button class="btn btn-ghost btn-sm" data-w="1280" data-h="720" data-fps="15" onclick="setCameraMode(this)">1280×720 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="1920" data-h="1080" data-fps="8" onclick="setCameraMode(this)">1920×1080 · 8</button>
</div>
<div style="font-size:.7rem;color:var(--dim);margin:.35rem 0 .2rem">JPEG Quality</div>
<div class="row" style="gap:.25rem" id="rec-quality-buttons">
<button class="btn btn-ghost btn-sm" data-q="50" onclick="setCameraQuality(this)">Low</button>
<button class="btn btn-ghost btn-sm" data-q="70" onclick="setCameraQuality(this)">Med</button>
<button class="btn btn-ghost btn-sm" data-q="85" onclick="setCameraQuality(this)">High</button>
</div>
</div>
<div style="margin-top:.3rem;font-size:.6rem;color:var(--dim)">
Each button rebuilds the capture pipeline (~0.5 s). Modes match the
RealSense D435I colour sensor — on USB 2.x, stick to 424×240 or 640×480.
If the feed is grayscale/IR, pin the colour node with <code>SANAD_CAMERA_USB_INDEX</code>.
</div>
</div>
<!-- Add New Face -->
<div class="card">
<h3>Add New Face</h3>
<div class="row">
<label>Name</label>
<input id="rec-newface-name" placeholder="(optional)" style="flex:1">
</div>
<div style="margin-top:.4rem">
<label style="font-size:.72rem;color:var(--dim)">Description — who is this person? (Gemini reads it)</label>
<textarea id="rec-newface-desc" rows="2" placeholder="e.g. Qassam, lead engineer on the robotics team — likes coffee" style="width:100%;margin-top:.2rem;font-size:.78rem;resize:vertical"></textarea>
</div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-success btn-sm" onclick="enrollFromCamera(this)" title="Snap current frame">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">
📁 Upload images
<input type="file" id="rec-upload-input" multiple accept="image/jpeg,image/png" style="display:none" onchange="enrollFromUpload(this)">
</label>
</div>
<div style="margin-top:.4rem;font-size:.65rem;color:var(--dim)">
Tip: add 23 photos / different angles per person for best recognition.
The description is sent to Gemini with the photos — it can then greet
and talk about the person using what you wrote.
</div>
</div>
<!-- Enrolled Faces -->
<div class="card card-full">
<h3>Enrolled Faces <span id="rec-faces-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div class="row">
<button class="btn btn-ghost btn-sm" onclick="refreshFaces()">↻ Refresh</button>
<span style="margin-left:auto;font-size:.65rem;color:var(--dim)" id="rec-gallery-version"></span>
</div>
<div id="rec-faces-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
</div>
<!-- Zone Recognition toggle + active destination -->
<div class="card card-full">
<h3>Zones &amp; Places</h3>
<div class="row" style="gap:1rem;flex-wrap:wrap;align-items:center">
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Zone Recognition</label>
<label class="switch">
<input type="checkbox" id="rec-zonerec-toggle" onchange="setZoneRecEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-zonerec-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<button class="btn btn-ghost btn-sm" onclick="syncZones(this)" title="Re-send zones/places to live Gemini session">↻ Sync</button>
<span style="margin-left:auto;font-size:.65rem;color:var(--dim)" id="rec-zones-version"></span>
</div>
<div class="row" style="margin-top:.5rem;gap:.4rem;align-items:center">
<span style="font-size:.72rem;color:var(--dim)">Destination:</span>
<span id="rec-nav-target" style="font-size:.78rem">none</span>
<button class="btn btn-ghost btn-sm" id="rec-nav-clear" onclick="clearNavTarget(this)" style="display:none">Clear destination</button>
</div>
<div style="margin-top:.4rem;font-size:.7rem;color:var(--dim)">
Group locations into <b>zones</b>, add <b>places</b> inside each (name + description +
optional reference photos), and link saved <b>faces</b> to a place. “Go here” sets a
destination and shows Gemini the place — the robot drives there once movement
(locomotion) is enabled.
</div>
</div>
<!-- Add New Zone -->
<div class="card card-full">
<h3>Add New Zone</h3>
<div class="row" style="flex-wrap:wrap;gap:.4rem">
<input id="rec-newzone-name" placeholder="Zone name (e.g. Ground Floor)" style="flex:1;min-width:12rem">
<input id="rec-newzone-desc" placeholder="Description (optional)" style="flex:2;min-width:12rem">
<button class="btn btn-success btn-sm" onclick="createZone(this)"> Add zone</button>
</div>
</div>
<!-- Zones list -->
<div class="card card-full">
<h3>Zones <span id="rec-zones-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div class="row">
<button class="btn btn-ghost btn-sm" onclick="refreshZones()">↻ Refresh</button>
</div>
<div id="rec-zones-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
</div>
</div>
</div>
<!-- ==================== TAB: Temperature ==================== -->
<div class="tab-content" id="tab-temp">
<!-- Live G1 battery (BMS) — soc / voltage / current / pack temp. -->
<div class="card card-full" id="battery-card" style="margin-bottom:.7rem">
<h3>🔋 Battery</h3>
<div class="row" style="align-items:center;gap:1.2rem;flex-wrap:wrap;margin-bottom:0">
<div style="display:flex;align-items:center;gap:.7rem">
<div class="batt-icon"><div class="batt-fill" id="batt-fill"></div></div>
<div>
<div id="batt-soc" style="font-size:1.7rem;font-weight:700;line-height:1">--%</div>
<div id="batt-status" style="font-size:.72rem;color:var(--dim)">--</div>
</div>
</div>
<div style="display:flex;gap:1.4rem;flex-wrap:wrap;font-size:.74rem;color:var(--muted)">
<div>Voltage<br><strong id="batt-volt" style="color:var(--text);font-size:.95rem">--</strong></div>
<div>Current<br><strong id="batt-cur" style="color:var(--text);font-size:.95rem">--</strong></div>
<div>Pack temp<br><strong id="batt-temp" style="color:var(--text);font-size:.95rem">--</strong></div>
<div>Cycles<br><strong id="batt-cycle" style="color:var(--text);font-size:.95rem">--</strong></div>
</div>
</div>
<div id="batt-msg" style="font-size:.68rem;color:var(--dim);margin-top:.5rem">Reading battery…</div>
</div>
<div class="card card-full" style="padding:0;overflow:hidden">
<iframe id="temp3d-frame" title="G1 Motor Temperature (3D)"
style="width:100%;height:80vh;border:0;display:block;background:#0b0d12"
src="about:blank"></iframe>
</div>
<div style="margin-top:.4rem;font-size:.65rem;color:var(--dim)">
Live motor surface/winding temperatures from <code>rt/lowstate</code> on the full
G1 (29 DOF). Blue ≈ 30&deg;C → red ≈ 120&deg;C. Drag to orbit, scroll to zoom.
Streamed over <code>/ws/motor-temps</code> — no second DDS subscriber.
</div>
</div>
<!-- ==================== TAB: Controller (N2) ==================== -->
<div class="tab-content" id="tab-controller">
<!-- Sticky status bar -->
<div class="card card-full" id="ctrl-statusbar" style="position:sticky;top:0;z-index:50">
<div class="row" style="justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<div class="row" style="gap:.6rem;align-items:center">
<span class="badge" id="ctrl-fsm-badge">FSM —</span>
<span class="dot" id="ctrl-ready-dot"></span><span id="ctrl-ready-text" style="font-size:.72rem">unknown</span>
<span class="badge badge-info" id="ctrl-msc-badge">MSC —</span>
<span class="badge" id="ctrl-sdk-badge">SDK —</span>
</div>
<div class="row" style="gap:.8rem;align-items:center">
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none">
<input type="checkbox" id="ctrl-arm-toggle" style="width:auto" onchange="ctrlSetArmed(this.checked)"> Enable movement
</label>
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Voice-driven locomotion — Gemini moves the robot when asked (EN/AR)">
<input type="checkbox" id="ctrl-gmove-toggle" style="width:auto" onchange="ctrlSetGeminiMove(this.checked)"> Enable Gemini movement
</label>
<button class="btn btn-danger" onclick="ctrlEstop(this)">E-STOP</button>
</div>
</div>
<!-- mirrored subsystem strip + coming-soon gates -->
<div class="status-pills ctrl-strip" style="margin-top:.5rem">
<span class="hdr-badge pill-off" id="ctrl-pill-camera">CAM</span>
<span class="hdr-badge pill-off" id="ctrl-pill-face">FACE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-place">PLACE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-movement">MOVE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-gmove" title="Gemini voice-driven locomotion">GEMINI-MOVE</span>
<span class="hdr-badge pill-soon" title="Phase 4 — autonomous navigation">EXPLORE · soon</span>
</div>
<div style="font-size:.66rem;color:var(--dim);margin-top:.45rem">
Manual operator control. Robot is assumed standing in walking mode — use <b>Ready/Start</b> only if needed.
All controls below are locked until <b>Enable movement</b> is on; <b>E-STOP</b> always works.
While movement is on, arm replays/actions are disabled (and vice-versa).
</div>
</div>
<div class="grid">
<!-- Locomotion / Teleop -->
<div class="card">
<h3>Locomotion / Teleop</h3>
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.4rem">Discrete step pad</div>
<div id="ctrl-steppad" class="steppad">
<button class="btn btn-ghost" onclick="ctrlStep('rotate_left',this)" title="Rotate left"></button>
<button class="btn btn-ghost" onclick="ctrlStep('forward',this)" title="Forward"></button>
<button class="btn btn-ghost" onclick="ctrlStep('rotate_right',this)" title="Rotate right"></button>
<button class="btn btn-ghost" onclick="ctrlStep('slide_left',this)" title="Slide left"></button>
<button class="btn btn-danger" onclick="ctrlStop(this)" title="Stop"></button>
<button class="btn btn-ghost" onclick="ctrlStep('slide_right',this)" title="Slide right"></button>
<button class="btn btn-ghost" style="visibility:hidden"></button>
<button class="btn btn-ghost" onclick="ctrlStep('backward',this)" title="Backward"></button>
<button class="btn btn-ghost" style="visibility:hidden"></button>
</div>
<div class="row" style="margin-top:.7rem;flex-wrap:wrap;gap:.5rem;align-items:center">
<button class="btn btn-primary" id="ctrl-teleop-btn" onclick="ctrlToggleTeleop()">Start teleop (WASD / Q-E)</button>
<label class="row" style="margin:0;gap:.35rem;align-items:center;text-transform:none"><input type="checkbox" id="ctrl-run-toggle" style="width:auto"> Run (1.2)</label>
</div>
<div id="ctrl-vel-readout" style="font-size:.7rem;color:var(--muted);margin-top:.4rem">vx 0.00 · vy 0.00 · ω 0.00</div>
<div style="font-size:.64rem;color:var(--dim);margin-top:.25rem">W/S forward·back · Q/E strafe · A/D rotate · Space halt</div>
</div>
<!-- Postures & Modes -->
<div class="card">
<h3>Postures &amp; Modes</h3>
<div class="row" style="flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost" onclick="ctrlMode('prep',this)" title="StopMove→Damp→StandUp→height ramp">PREP</button>
<button class="btn btn-primary" onclick="ctrlMode('ready',this)" title="PREP + Start (FSM 200)">READY / START</button>
<button class="btn btn-ghost" onclick="ctrlPosture('stand_up',this)">StandUp</button>
<button class="btn btn-ghost" onclick="ctrlPosture('squat',this)">Squat</button>
<button class="btn btn-ghost" onclick="ctrlPosture('sit',this)">Sit</button>
<button class="btn btn-ghost" onclick="ctrlPosture('low_stand',this)">LowStand</button>
<button class="btn btn-ghost" onclick="ctrlPosture('high_stand',this)">HighStand</button>
<button class="btn btn-ghost" onclick="ctrlPosture('lie_to_stand',this)">Lie→Stand</button>
<button class="btn btn-danger" onclick="ctrlPosture('damp',this)">Damp</button>
<button class="btn btn-danger" onclick="ctrlPosture('zero_torque',this)">ZeroTorque</button>
</div>
</div>
<!-- MotionSwitcher / Low-Level -->
<div class="card">
<h3>MotionSwitcher / Low-Level</h3>
<div class="row" style="flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost" onclick="ctrlMscSelectAi(this)">Select AI</button>
<button class="btn btn-ghost" onclick="ctrlMscRelease(this)">Release</button>
<button class="btn btn-ghost" onclick="ctrlMscShow(this)">Show mode</button>
<button class="btn btn-ghost" onclick="ctrlBalance(0,this)">Balance: static</button>
<button class="btn btn-ghost" onclick="ctrlBalance(1,this)">Balance: gait</button>
<button class="btn btn-ghost" onclick="ctrlReconnect(this)">Reconnect</button>
</div>
</div>
<!-- Diagnostics -->
<div class="card">
<h3>Diagnostics — joints 1228</h3>
<pre id="ctrl-joints" style="font-size:.66rem;max-height:240px;overflow:auto;background:var(--panel2);border-radius:6px;padding:.5rem;margin:0"></pre>
</div>
</div>
</div>
<!-- ==================== TAB: Navigation ==================== -->
<div class="tab-content" id="tab-navigation">
<!-- Sticky status bar -->
<div class="card card-full" id="nav-statusbar" style="position:sticky;top:0;z-index:50">
<div class="row" style="justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<div class="row" style="gap:.6rem;align-items:center">
<span class="badge" id="nav-state-badge">NAV —</span>
<span class="dot" id="nav-ready-dot"></span><span id="nav-ready-text" style="font-size:.72rem">unknown</span>
<span class="badge" id="nav-bringup-badge">BRINGUP —</span>
<span class="badge" id="nav-bridge-badge">BRIDGE —</span>
<span class="badge" id="nav-mode-badge" title="What the single robot/bringup is doing right now">MODE —</span>
</div>
<div class="row" style="gap:.8rem;align-items:center">
<button class="btn btn-ghost btn-sm" onclick="refreshNavigation()">↻ Refresh</button>
<button class="btn btn-danger" onclick="navCancel(this)">CANCEL / STOP</button>
</div>
</div>
<div style="font-size:.66rem;color:var(--dim);margin-top:.45rem">
Autonomous navigation via <b>web_nav3</b> (Nav2 + rosbridge on the robot). Saved <b>places</b>
let you send a goal with one click; the robot drives there once locomotion is enabled.
The full nav dashboard (live map, set-pose, manual goals) is also available at
<a id="nav-link" href="#" target="_blank" style="color:var(--accent)">:8765</a>.
</div>
</div>
<div class="grid">
<!-- Places -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Places <span id="nav-places-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div id="nav-places-list" style="margin-top:.4rem"><div class="empty">Loading…</div></div>
</div>
<!-- Save current pose -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save current pose as place</h3>
<div class="row" style="flex-wrap:wrap;gap:.4rem;margin-top:.2rem">
<input id="nav-save-name" placeholder="Place name (e.g. Reception)" style="flex:1;min-width:11rem" onkeydown="if(event.key==='Enter')navSaveHere(document.getElementById('nav-save-btn'))">
<button class="btn btn-success btn-sm" id="nav-save-btn" onclick="navSaveHere(this)"> Save here</button>
</div>
<div style="font-size:.64rem;color:var(--dim);margin-top:.5rem">
Captures the robot's current map pose under this name. Drive (or teleop) the robot to the
spot first, then save. Saved places appear in the list to the left.
</div>
</div>
<!-- Missions -->
<div class="card card-full">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Missions <span id="nav-missions-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div id="nav-missions-list" style="margin-top:.4rem"><div class="empty">Loading…</div></div>
<div style="font-size:.64rem;color:var(--dim);margin-top:.5rem">
Multi-waypoint routes / patrols defined in web_nav3. Run executes the full sequence.
</div>
</div>
<!-- Live map -->
<div class="card card-full" style="margin-top:12px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<h3 style="margin:0">MAP NAVIGATION</h3>
<select id="navMapSelect" style="background:#1c212c;color:#e5e9f0;border:1px solid #2f3645;border-radius:5px;padding:5px 8px;font-size:12px"></select>
<button class="action-btn" onclick="navLoadMap(this)">Load &amp; View</button>
<span class="r-meta" style="margin-left:2px">map: <b id="navMapLabel"></b></span>
<span style="flex:1"></span>
<span class="r-meta">Click:</span>
<button class="action-btn running" id="navMode_view" onclick="navSetMode('view')">View</button>
<button class="action-btn" id="navMode_goal" onclick="navSetMode('goal')">🧭 Goal</button>
<button class="action-btn" id="navMode_add" onclick="navSetMode('add')">📍 Add place</button>
<span style="width:8px"></span>
<button class="action-btn" onclick="navMapZoom(-1)"></button>
<button class="action-btn" onclick="navMapZoom(0)">Fit</button>
<button class="action-btn" onclick="navMapZoom(1)">+</button>
<span id="navMapStatus" class="r-meta">connecting…</span>
</div>
<div style="background:#06080d;border-radius:6px;padding:8px;overflow:auto;max-height:60vh;display:flex;align-items:center;justify-content:center">
<canvas id="navMapCanvas" width="500" height="500" style="image-rendering:pixelated;max-width:100%;height:auto;border-radius:4px"></canvas>
</div>
<div class="r-meta" style="margin-top:6px"><span id="navModeHint">VIEW — pick GOAL to drive, or ADD to bookmark places.</span> Cyan = robot, green dots = this map's places. Each map has its own places. Use the <b>Places</b> list to Go / Move / rename / delete.</div>
</div>
</div>
</div>
<!-- ==================== TAB: Live Map ==================== -->
<div class="tab-content" id="tab-livemap">
<div class="card card-full" style="padding:0;overflow:hidden">
<div style="padding:.7rem 1.1rem .4rem"><h3 style="margin:0">Live Map — full web_nav3 dashboard</h3></div>
<iframe id="livemapFrame" title="web_nav3 full dashboard"
src="about:blank"
allow="clipboard-write; clipboard-read"
style="width:100%;height:78vh;border:0;border-radius:8px;display:block;background:#0b0d12"></iframe>
</div>
<div class="card-full" style="font-size:.64rem;color:var(--dim);margin-top:-.4rem">
Full <b>web_nav3</b> dashboard (live map, set-pose, manual goals, missions) embedded from the robot.
Also available standalone at
<a id="livemap-link" href="#" target="_blank" style="color:var(--accent)">:8765</a>.
If it stays blank, check that bringup + rosbridge are alive (see the Navigation tab) and that the
robot is reachable on the network.
</div>
</div>
<!-- ==================== TAB: Map Editor ==================== -->
<div class="tab-content" id="tab-mapeditor">
<div class="card card-full">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<h3 style="margin:0">MAP EDITOR</h3>
<select id="medMapSelect" style="background:#1c212c;color:#e5e9f0;border:1px solid #2f3645;border-radius:5px;padding:5px 8px;font-size:12px"></select>
<button class="action-btn" onclick="medLoad(this)">Load &amp; Edit</button>
<span class="r-meta">map: <b id="medMapLabel"></b></span>
<span class="badge" id="med-mode-badge" title="What the single robot/bringup is doing right now">MODE —</span>
<span style="flex:1"></span>
<span class="r-meta">Tool:</span>
<button class="action-btn running" id="medTool_pan" onclick="medSetTool('pan')">✋ Pan</button>
<button class="action-btn" id="medTool_erase" onclick="medSetTool('erase')">🧽 Erase</button>
<button class="action-btn" id="medTool_wall" onclick="medSetTool('wall')">⬛ Wall</button>
<span class="r-meta">Brush</span>
<select id="medBrush" style="background:#1c212c;color:#e5e9f0;border:1px solid #2f3645;border-radius:5px;padding:4px"><option value="1">1</option><option value="3" selected>3</option><option value="5">5</option><option value="9">9</option><option value="15">15</option></select>
<button class="action-btn" onclick="medUndo()">↶ Undo</button>
<button class="action-btn" onclick="medClearEdits()">Clear edits</button>
<button class="btn btn-success btn-sm" onclick="medSave(this)">💾 Save</button>
<span style="width:6px"></span>
<button class="action-btn" onclick="medZoom(-1)"></button>
<button class="action-btn" onclick="medZoom(0)">Fit</button>
<button class="action-btn" onclick="medZoom(1)">+</button>
<span id="medStatus" class="r-meta">load a map…</span>
</div>
<div id="medWrap" style="background:#06080d;border-radius:6px;padding:8px;overflow:auto;max-height:72vh;display:flex;align-items:center;justify-content:center">
<canvas id="medCanvas" width="500" height="500" style="image-rendering:pixelated;max-width:100%;height:auto;border-radius:4px;cursor:default"></canvas>
</div>
<div class="r-meta" style="margin-top:6px">
<b>Edit a SAVED map.</b> Pick a map → <b>Load &amp; Edit</b>. <b>🧽 Erase</b> removes black phantom obstacles (paints them free); <b>⬛ Wall</b> paints virtual walls / keep-outs. Click-drag to paint (brush size above). <b>Save</b> stores the edits per-map and applies them to the robot's navigation — it stops avoiding erased points and treats painted walls as keep-outs. <b>Yellow = your edits.</b> The original map <code>.db</code> is never modified.
</div>
</div>
</div>
<!-- ==================== TAB: Mask Face ==================== -->
<div class="tab-content" id="tab-mask">
<!-- Connection / status bar -->
<div class="card card-full" id="mask-statusbar" style="position:sticky;top:0;z-index:50">
<div class="row" style="justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<div class="row" style="gap:.6rem;align-items:center">
<span class="dot" id="mask-conn-dot"></span>
<span id="mask-conn-text" style="font-size:.75rem">unknown</span>
<span class="badge" id="mask-face-badge">FACE —</span>
<span class="badge" id="mask-speak-badge">SPEAK —</span>
</div>
<div class="row" style="gap:.5rem">
<button class="btn btn-success btn-sm" id="mask-connect-btn" onclick="maskConnect(this)">Connect</button>
<button class="btn btn-ghost btn-sm" onclick="maskDisconnect(this)">Disconnect</button>
<button class="btn btn-ghost btn-sm" onclick="refreshMask()">Refresh</button>
</div>
</div>
<div id="mask-note" style="font-size:.66rem;color:var(--dim);margin-top:.45rem">
LED face mask over Bluetooth. Check <b>Link Gemini</b> (below) to connect it + let Gemini show emotions; leave it off and the mask stays idle (no reconnecting).
Once linked it <b>self-heals dropped links</b> — keep the mask near the Jetson and free it from the phone app first. Faces upload once (~25&nbsp;s) then animate via PLAY.
</div>
</div>
<div class="grid">
<!-- Animated face -->
<div class="card">
<h3>Animated Face</h3>
<div class="row" style="flex-wrap:wrap;gap:.4rem">
<button class="btn btn-primary" onclick="maskFaceStart(this,false)" title="Upload frames once (~25s first time) + start idle blink/glance + lip-sync">▶ Run face</button>
<button class="btn btn-success btn-sm" onclick="maskReturnFace(this)" title="Resume the live face after showing text/image/animation">↩ Return to face</button>
<button class="btn btn-ghost btn-sm" onclick="maskFaceStart(this,true)" title="Force re-upload of the frame set">Reload</button>
<button class="btn btn-danger btn-sm" onclick="maskFaceStop(this)">Stop face</button>
</div>
<div class="row" style="margin-top:.7rem;gap:.9rem;align-items:center">
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Link Gemini to the mask. ON: connect the mask + let Gemini show emotions/social on it. OFF (default): the mask stays idle (no reconnecting) and Gemini won't touch it.">
<input type="checkbox" id="mask-link-toggle" style="width:auto" onchange="maskLink(this.checked)"> <b style="color:#4ea1ff">Link Gemini</b>
</label>
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Animate the mouth while speaking">
<input type="checkbox" id="mask-speak-toggle" style="width:auto" onchange="maskSpeaking(this.checked)"> Speaking
</label>
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Hide the mouth (eyes only) — re-uploads a few frames (~1 min)">
<input type="checkbox" id="mask-hidemouth-toggle" style="width:auto" onchange="maskMouthHidden(this.checked)"> Hide mouth
</label>
</div>
<div class="row" style="margin-top:.5rem;gap:.5rem;align-items:center">
<span style="font-size:.7rem;color:var(--muted)">Mouth</span>
<input type="range" id="mask-mouth" min="0" max="3" step="1" value="0" oninput="maskMouth(this.value)" style="flex:1">
<span id="mask-mouth-val" style="font-size:.7rem;width:1rem">0</span>
</div>
<div class="row" style="margin-top:.6rem;flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="maskExpr('neutral',this)">Neutral</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('smile',this)">Smile</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('blink',this)">Blink</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('wink',this)">Wink</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('surprised',this)">Surprised</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('sad',this)">Sad</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('angry',this)">Angry</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('heart',this)">❤️ Heart</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('laugh',this)">😂 Laugh</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('love',this)">😍 Love</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('cool',this)">😎 Cool</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('confused',this)">🤔 Confused</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('kiss',this)">😘 Kiss</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('thumbs_up',this)">👍 Thumbs</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('look_left',this)">Look ◀</button>
<button class="btn btn-ghost btn-sm" onclick="maskExpr('look_right',this)">Look ▶</button>
</div>
<div class="row" style="margin-top:.7rem;gap:.5rem;align-items:center;flex-wrap:wrap">
<span style="font-size:.7rem;color:var(--muted)">Face colors</span>
<label style="font-size:.62rem;color:var(--dim);display:flex;align-items:center;gap:.2rem;margin:0">Eyes <input type="color" id="mask-eye-color" value="#00e6ff" style="width:34px;padding:1px"></label>
<label style="font-size:.62rem;color:var(--dim);display:flex;align-items:center;gap:.2rem;margin:0">Mouth <input type="color" id="mask-mouth-color" value="#ff3232" style="width:34px;padding:1px"></label>
<label style="font-size:.62rem;color:var(--dim);display:flex;align-items:center;gap:.2rem;margin:0">Sclera <input type="color" id="mask-sclera-color" value="#ffffff" style="width:34px;padding:1px"></label>
<button class="btn btn-primary btn-sm" onclick="maskFaceColor(this)" title="Recolor the face — re-uploads frames if the face is live (~30-60s)">Apply colors</button>
</div>
<div style="font-size:.64rem;color:var(--dim);margin-top:.4rem">Run face → it blinks/glances on its own and lip-syncs while Gemini speaks. Colors re-upload the frames (~3060s). Auto-reconnect keeps the face alive through BLE drops.</div>
</div>
<!-- Brightness & Text -->
<div class="card">
<h3>Brightness &amp; Text</h3>
<div class="row" style="gap:.5rem;align-items:center">
<span style="font-size:.7rem;color:var(--muted)">Brightness</span>
<input type="range" id="mask-bright" min="0" max="128" step="1" value="95" onchange="maskBrightness(this.value)" style="flex:1">
<span id="mask-bright-val" style="font-size:.7rem;width:2rem">95</span>
</div>
<div style="font-size:.62rem;color:var(--dim);margin:.3rem 0 .6rem">Keep ≤100 to avoid LED flicker (battery-limited).</div>
<div class="row" style="gap:.4rem;flex-wrap:wrap;align-items:center">
<input type="text" id="mask-text" placeholder="Scrolling text…" style="flex:1;min-width:140px">
<input type="color" id="mask-color" value="#00e6ff" title="Text color" style="width:42px;padding:2px">
<select id="mask-text-mode">
<option value="3">Scroll ←</option>
<option value="4">Scroll →</option>
<option value="2">Blink</option>
<option value="1">Steady</option>
</select>
<button class="btn btn-primary btn-sm" onclick="maskText(this)">Send text</button>
</div>
<div class="row" style="gap:.5rem;flex-wrap:wrap;align-items:center;margin-top:.45rem">
<label style="font-size:.62rem;color:var(--dim);display:flex;align-items:center;gap:.25rem;margin:0" title="Custom background color"><input type="checkbox" id="mask-text-bg-on" style="width:auto"> BG <input type="color" id="mask-text-bg" value="#000000" style="width:34px;padding:1px"></label>
<span style="font-size:.62rem;color:var(--muted)">Speed</span>
<input type="range" id="mask-text-speed" min="0" max="255" step="5" value="40" style="flex:1;min-width:80px" title="Scroll speed">
<button class="btn btn-ghost btn-sm" onclick="maskReturnFace(this)" title="Resume the live animated face">↩ Face</button>
</div>
<div style="font-size:.62rem;color:var(--dim);margin-top:.4rem">Text overrides the animated face until you Run face / ↩ Face again.</div>
</div>
<!-- Social / QR on the face -->
<div class="card">
<h3>Social / QR on Face</h3>
<div class="row" style="gap:.4rem;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" onclick="maskSocial('bu_sunaidah',this)" title="Show a scannable QR for @bu.sunaidah">📷 @bu.sunaidah</button>
<button class="btn btn-primary btn-sm" onclick="maskSocial('yslootahtech',this)" title="Show a scannable QR for @yslootahtech">📷 @yslootahtech</button>
<label class="btn btn-ghost btn-sm" style="cursor:pointer;margin:0" title="Show any QR/image once (not saved)">⬆ Show once<input type="file" accept="image/*" style="display:none" onchange="maskQrUpload(this)"></label>
<button class="btn btn-success btn-sm" onclick="maskReturnFace(this)" title="Stop the code and resume the animated face">↩ Back to face</button>
</div>
<div style="font-size:.62rem;color:var(--dim);margin:.4rem 0">Social buttons show a <b>scannable QR</b> (short da.gd link → Instagram). Full-URL / dense QRs show full-screen but only scan if short (use <b>QR from link</b> below).</div>
<!-- saved QR library -->
<div class="row" style="gap:.4rem;flex-wrap:wrap;align-items:center;margin-top:.3rem;padding-top:.4rem;border-top:1px solid var(--border,#2a2a2a)">
<span style="font-size:.7rem;color:var(--muted)">Saved QR codes</span>
<input type="text" id="qr-save-name" placeholder="name…" style="flex:0 1 130px">
<label class="btn btn-ghost btn-sm" style="cursor:pointer;margin:0" title="Pick a QR image and save it to the library">⬆ Add + Save<input type="file" id="qr-save-file" accept="image/*" style="display:none" onchange="qrSave(this)"></label>
<button class="btn btn-ghost btn-sm" onclick="qrLoadLibrary()"></button>
</div>
<div class="row" style="gap:.4rem;flex-wrap:wrap;align-items:center;margin-top:.35rem">
<span style="font-size:.66rem;color:var(--muted)" title="A SHORT link (≤ ~17 chars, e.g. bit.ly/…) makes a version-1 QR that can actually scan on the mask">QR from link</span>
<input type="text" id="qr-link-url" placeholder="short link e.g. bit.ly/lootah" style="flex:1;min-width:150px">
<input type="text" id="qr-link-name" placeholder="name…" style="flex:0 1 100px">
<button class="btn btn-ghost btn-sm" onclick="qrSaveLink(this)"> Make QR</button>
</div>
<div id="qr-library" style="display:flex;flex-wrap:wrap;gap:.55rem;margin-top:.55rem"></div>
</div>
<!-- Saved text / words -->
<div class="card">
<h3>Saved Text / Words</h3>
<div class="row" style="gap:.4rem;flex-wrap:wrap;align-items:center">
<input type="text" id="text-save-input" placeholder="Type a word or phrase…" style="flex:1;min-width:150px" onkeydown="if(event.key==='Enter')textSave(this)">
<button class="btn btn-ghost btn-sm" onclick="textSave(document.getElementById('text-save-input'))"> Save</button>
<button class="btn btn-success btn-sm" onclick="maskReturnFace(this)" title="Stop the text and resume the animated face">↩ Back to face</button>
</div>
<div style="font-size:.62rem;color:var(--dim);margin:.4rem 0">Save words/phrases, then scroll any of them across the mask on demand.</div>
<div id="text-library" style="display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.3rem"></div>
</div>
<!-- Built-ins -->
<div class="card">
<h3>Built-in Images / Animations</h3>
<div class="row" style="gap:.3rem;align-items:center;flex-wrap:wrap">
<span style="font-size:.7rem;color:var(--muted);width:2.6rem">Image</span>
<button class="btn btn-ghost btn-sm" onclick="maskStep('mask-img-id',-1,0,105,maskImage)" title="Previous image"></button>
<input type="number" id="mask-img-id" min="0" max="105" value="1" style="width:60px">
<button class="btn btn-ghost btn-sm" onclick="maskStep('mask-img-id',1,0,105,maskImage)" title="Next image"></button>
<button class="btn btn-ghost btn-sm" onclick="maskImage(this)">Show</button>
</div>
<div class="row" style="gap:.3rem;align-items:center;flex-wrap:wrap;margin-top:.4rem">
<span style="font-size:.7rem;color:var(--muted);width:2.6rem">Anim</span>
<button class="btn btn-ghost btn-sm" onclick="maskStep('mask-anim-id',-1,0,69,maskAnim)" title="Previous animation"></button>
<input type="number" id="mask-anim-id" min="0" max="69" value="1" style="width:60px">
<button class="btn btn-ghost btn-sm" onclick="maskStep('mask-anim-id',1,0,69,maskAnim)" title="Next animation"></button>
<button class="btn btn-ghost btn-sm" onclick="maskAnim(this)">Play</button>
</div>
<div class="row" style="margin-top:.7rem">
<button class="btn btn-danger btn-sm" onclick="maskClear(this)" title="Delete all uploaded DIY frames from the mask flash">Clear DIY frames</button>
</div>
<div style="font-size:.62rem;color:var(--dim);margin-top:.4rem">Built-in IMAG ids ~0105, ANIM ids ~069 (values above range show garbled frames).</div>
</div>
</div>
</div>
<!-- ==================== TAB: Recordings ==================== -->
<div class="tab-content" id="tab-recordings">
<div class="grid">
<!-- Skill Registry -->
<div class="card card-full">
<h3>Skill Registry</h3>
<div id="skills-list"><div class="empty">No skills configured</div></div>
<button class="btn btn-ghost btn-sm" onclick="refreshSkills()" style="margin-top:.4rem">Refresh</button>
</div>
<!-- Saved Records -->
<div class="card card-full">
<h3>Saved Records</h3>
<!-- Live-Gemini pause mode. Unchecked = AUTO (records pause Gemini only for
the clip, then resume). Checked = HOLD (Gemini pauses and STAYS paused
until unchecked). -->
<div class="row" style="gap:.5rem;align-items:center;margin-bottom:.5rem">
<label style="display:flex;gap:.4rem;align-items:center;font-size:.78rem;cursor:pointer">
<input type="checkbox" id="rec-live-hold" onchange="toggleLiveHold(this)">
Keep Gemini paused (hold)
</label>
<span id="rec-live-hold-state" style="font-size:.7rem;color:var(--dim)">Auto — resumes after each clip</span>
</div>
<!-- Bulk-delete controls: select-all, delete selected, delete all. -->
<div class="row" style="gap:.5rem;align-items:center;margin-bottom:.5rem;flex-wrap:wrap">
<label style="display:flex;gap:.35rem;align-items:center;font-size:.78rem;cursor:pointer">
<input type="checkbox" id="rec-select-all" onchange="recSelectAll(this)">
Select all
</label>
<button class="btn btn-danger btn-sm" id="rec-del-selected" onclick="deleteSelectedRecords()" disabled>Delete selected (0)</button>
<button class="btn btn-danger btn-sm" onclick="deleteAllRecords()">Delete all</button>
<span id="rec-total" style="font-size:.7rem;color:var(--dim);margin-left:auto"></span>
</div>
<div id="records-list"><div class="empty">No records saved</div></div>
<button class="btn btn-ghost btn-sm" onclick="refreshRecords()" style="margin-top:.3rem">Refresh</button>
</div>
</div>
</div>
<!-- ==================== TAB: Terminal ==================== -->
<!-- In-browser shell on the robot. WebSocket → PTY bridge in
dashboard/websockets/terminal.py. Click "SSH" to spawn a shell;
click again or close the tab to terminate.
xterm.js + xterm-addon-fit loaded from jsdelivr (no bundler needed). -->
<div class="tab-content" id="tab-terminal">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<div class="card" style="display:flex;flex-direction:column;height:calc(100vh - 220px);min-height:480px">
<div class="row" style="align-items:center;gap:.4rem">
<h3 style="margin:0;flex:1">Terminal — unitree@robot</h3>
<span id="term-status" style="font-size:.7rem;color:var(--dim)">disconnected</span>
<button id="term-ssh-btn" class="btn btn-primary btn-sm" onclick="termConnect(this)">SSH</button>
<button id="term-stop-btn" class="btn btn-danger btn-sm" onclick="termDisconnect(this)" disabled>Disconnect</button>
<button class="btn btn-ghost btn-sm" onclick="termClear()" title="Clear screen (Ctrl+L also works)">Clear</button>
</div>
<div style="font-size:.65rem;color:var(--dim);margin-top:.2rem">
Runs as the dashboard's user on the robot (typically <code>unitree</code>). No SSH handshake — the dashboard is already on the robot. Works on whichever Wi-Fi the robot is connected to.
</div>
<div id="term-host" style="flex:1;margin-top:.5rem;background:#000;border-radius:6px;padding:.3rem;overflow:hidden"></div>
</div>
</div>
<!-- ==================== TAB: Settings & Logs ==================== -->
<div class="tab-content" id="tab-settings">
<div class="grid">
<!-- Scripts -->
<div class="card">
<h3>Scripts Manager</h3>
<div class="row"><select id="script-select" style="flex:1" onchange="loadScript(this.value)"><option value="">-- select --</option></select><button class="btn btn-ghost btn-sm" onclick="refreshScripts()">Refresh</button></div>
<div class="row" style="margin-top:.3rem;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.72rem">
<span style="color:var(--dim)">Gemini persona:</span>
<b id="script-active" style="color:var(--accent)"></b>
<span style="flex:1"></span>
<button class="btn btn-success btn-sm" onclick="useForGemini()" title="Make Gemini load the selected script as its persona">▶ Use selected for Gemini</button>
<button class="btn btn-ghost btn-sm" onclick="resetPersona()" title="Revert Gemini to the default sanad_script.txt">↺ Default</button>
</div>
<div style="font-size:.62rem;color:var(--dim);margin-top:.2rem">Create variants (e.g. <code>sanad_script_v2.txt</code>) then select one and “Use for Gemini”. Default is always <code>sanad_script.txt</code>.</div>
<textarea id="script-content" placeholder="Script content..." style="min-height:100px"></textarea>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="saveScript()">Save</button>
<input id="script-new-name" placeholder="new_file.txt" style="flex:1">
<button class="btn btn-success btn-sm" onclick="createScript()">Create</button>
<button class="btn btn-danger btn-sm" onclick="deleteScript()">Delete</button>
</div>
</div>
<!-- Prompt -->
<div class="card">
<h3>Prompt Management</h3>
<div id="prompt-info" style="font-size:.7rem;color:var(--dim);margin-bottom:.3rem"></div>
<textarea id="prompt-content" placeholder="System prompt..." style="min-height:100px"></textarea>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="updatePrompt()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="reloadPrompt()">Reload from Disk</button>
</div>
</div>
<!-- Storage -->
<div class="card card-full">
<div class="row" style="align-items:center;gap:.5rem;flex-wrap:wrap">
<h3 style="margin:0">Storage</h3>
<span id="storage-summary" class="r-meta" style="color:var(--muted)"></span>
<span style="flex:1"></span>
<button class="btn btn-ghost btn-sm" onclick="refreshStorage()">↻ Refresh</button>
<button class="btn btn-danger btn-sm" onclick="cleanStorage('all')" title="Delete conversation recordings + named records, and clear logs">🧹 Clean all (disposable)</button>
</div>
<div id="storage-list" style="margin-top:.4rem">loading…</div>
<div style="font-size:.62rem;color:var(--dim);margin-top:.3rem">“Clean all” = recordings + named records + logs. Faces, motions &amp; zones are shown for tracking only — manage those in their own tabs.</div>
</div>
<!-- Logs -->
<div class="card card-full">
<h3>Live Logs</h3>
<div class="row" style="margin-bottom:.3rem;flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="saveLogSnapshot()" title="Save a timestamped copy of all .log files under logs/">Save Snapshot</button>
<button class="btn btn-primary btn-sm" onclick="copyAllLogs(this)" title="Fetch system status + every log file and copy to clipboard">Copy All</button>
<button class="btn btn-ghost btn-sm" onclick="copyVisibleLogs(this)" title="Copy only what's currently in the log box below">Copy Visible</button>
<button class="btn btn-ghost btn-sm" onclick="downloadLogBundle()" title="Download the full bundle as a .txt file">Download</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('log-box').textContent=''">Clear</button>
</div>
<div class="log-box" id="log-box" style="height:300px"></div>
</div>
</div>
</div>
<!-- roslibjs (+ eventemitter2 dependency) — used by the Navigation tab native map renderer -->
<script src="https://cdn.jsdelivr.net/npm/eventemitter2@6.4.9/lib/eventemitter2.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/roslib@1.4.1/build/roslib.min.js"></script>
<script>
const API='';
function toast(m,t='info'){const b=document.getElementById('toast-box'),e=document.createElement('div');e.className='toast toast-'+t;e.textContent=m;b.appendChild(e);setTimeout(()=>e.remove(),3500);}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function btnLoad(b){if(b&&b.classList)b.classList.add('loading');}
function btnDone(b){if(b&&b.classList)b.classList.remove('loading');}
async function api(m,p,b){const o={method:m,headers:{'Content-Type':'application/json'}};if(b)o.body=JSON.stringify(b);const r=await fetch(API+p,o);const j=await r.json();if(!r.ok){toast(j.detail||j.error||'Error '+r.status,'err');throw new Error(j.detail||j.error);}return j;}
// Tabs
function switchTab(name){
// Match the nav tab by its exact onclick target — NOT a substring of the
// label. "recognition" and "recordings" both start with "reco", so the old
// textContent.includes(name.slice(0,4)) lit up both tabs at once.
const want="switchTab('"+name+"')";
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',(t.getAttribute('onclick')||'').includes(want)));
document.querySelectorAll('.tab-content').forEach(c=>c.classList.toggle('active',c.id==='tab-'+name));
if(name==='livemap')loadLiveMapFrame();
if(name==='mask'&&typeof qrLoadLibrary==='function'){qrLoadLibrary();textLoadLibrary();}
}
// Live Map — lazy-load the full web_nav3 dashboard iframe once (kept loaded so
// state persists and we don't reload it on every tab switch).
let _livemapLoaded=false;
function loadLiveMapFrame(){
// Prefer the backend-resolved web_nav3 URL (env/config override via
// /api/nav/config); fall back to the host-derived :8765 default.
const url=(typeof navWebUrl==='function')?navWebUrl():(location.protocol+'//'+(location.hostname||'localhost')+':8765');
const label=(typeof navWebLabel==='function')?navWebLabel():(location.hostname||'localhost')+':8765';
const link=document.getElementById('livemap-link');
if(link){link.href=url;link.textContent=label;}
if(_livemapLoaded)return;
const f=document.getElementById('livemapFrame');
if(f && (!f.src || /about:blank$/.test(f.src))){f.src=url;_livemapLoaded=true;}
}
// Emergency Stop — halt EVERY actuator path: arm replay, voice→arm loop,
// locomotion legs (StopMove + disarm + dispatcher latch) and any in-flight
// Nav2 goal. Fire each independently so one failure can't block the rest.
async function emergencyStop(){
const calls=[['/api/replay/cancel'],['/api/live-voice/stop'],['/api/controller/estop'],['/api/nav/cancel']];
for(const [ep] of calls){ try{await api('POST',ep);}catch(e){} }
toast('EMERGENCY STOP sent','err');
}
// Voice
async function generate(b){btnLoad(b);try{const t=document.getElementById('voice-text').value,e=document.getElementById('engine').value;const r=await api('POST','/api/voice/generate',{text:t,engine:e});document.getElementById('voice-result').textContent=r.ok?'Done':'Failed';toast('Speech generated','ok');}catch(e){}btnDone(b);}
async function connectGemini(b){btnLoad(b);try{await api('POST','/api/voice/connect');toast('Gemini connected','ok');}catch(e){}btnDone(b);refreshStatus();}
async function disconnectGemini(b){btnLoad(b);try{await api('POST','/api/voice/disconnect');toast('Disconnected','info');}catch(e){}btnDone(b);refreshStatus();}
// Gemini API key — show masked current value + save a new one
async function refreshApiKey(b){
if(b)btnLoad(b);
try{
const r=await api('GET','/api/voice/api-key');
const inp=document.getElementById('gm-key-current');
const src=document.getElementById('gm-key-source');
const msg=document.getElementById('gm-key-msg');
if(r.has_key){
inp.value=r.masked||'';
inp.placeholder='';
src.textContent=r.source==='config_file'?'saved':'default';
src.className='badge '+(r.source==='config_file'?'badge-ok':'badge-warn');
msg.textContent=`Length: ${r.length} chars`;
}else{
inp.value='';
inp.placeholder='(no key configured)';
src.textContent='empty';
src.className='badge badge-err';
msg.textContent='No API key — paste one below to enable Gemini.';
}
}catch(e){}
if(b)btnDone(b);
}
function toggleApiKeyVisibility(){
const inp=document.getElementById('gm-key-new');
inp.type=inp.type==='password'?'text':'password';
}
async function saveApiKey(b){
const inp=document.getElementById('gm-key-new');
const key=(inp.value||'').trim();
const msg=document.getElementById('gm-key-msg');
if(!key){toast('Paste a key first','err');return;}
if(!key.startsWith('AIza')){
if(!confirm("Key doesn't start with 'AIza'. Gemini keys normally do. Save anyway?"))return;
}
btnLoad(b);
try{
const r=await api('POST','/api/voice/api-key',{api_key:key});
toast(`API key saved (${r.length} chars)`,'ok');
inp.value='';
inp.type='password';
msg.textContent=r.message||'Saved. Click Connect to apply.';
msg.style.color='var(--accent)';
setTimeout(()=>{msg.style.color='var(--muted)';},4000);
await refreshApiKey();
refreshStatus();
}catch(e){
msg.textContent='Save failed: '+(e.message||'unknown');
msg.style.color='#f55';
}
btnDone(b);
}
// System info
// Live System-Info stats (storage / battery / motor temp) — refreshed often.
async function refreshSysLive(){
const el=document.getElementById('sys-live'); if(!el) return;
const parts=[];
try{const s=await api('GET','/api/system/storage');const dk=s.disk||{};
parts.push(`<strong>Storage:</strong> ${esc(dk.free_human||'?')} free / ${esc(dk.total_human||'?')} (${dk.used_pct!=null?dk.used_pct+'% used':'?'}) · data ${esc(s.data_human||'?')}`);
}catch(e){}
try{const b=await api('GET','/api/temp/battery');
if(b&&b.available){
const ic=b.status==='charging'?'⚡':b.status==='discharging'?'▼':'•';
const col=b.soc>50?'#5fdc8a':b.soc>20?'#f59e0b':'#f55';
parts.push(`<strong>Battery:</strong> <span style="color:${col}">${b.soc}%</span> ${ic}${b.voltage_v!=null?' · '+b.voltage_v+'V':''}${b.current_a!=null?' · '+b.current_a+'A':''}${b.temp_c!=null?' · '+b.temp_c+'°C':''}`);
}else parts.push('<strong>Battery:</strong> <span style="color:var(--dim)">no BMS data</span>');
}catch(e){}
try{const m=await api('GET','/api/temp/motors');const ts=m.temperatures||[];
let mx=null,mn=null,sum=0,c=0;
ts.forEach(x=>[x.winding,x.surface].forEach(v=>{if(typeof v==='number'){if(mx==null||v>mx)mx=v;if(mn==null||v<mn)mn=v;sum+=v;c++;}}));
if(mx!=null){const col=mx>=80?'#f55':mx>=60?'#f59e0b':'#5fdc8a';
parts.push(`<strong>Motor temp:</strong> max <span style="color:${col}">${mx.toFixed(0)}°C</span> · avg ${(c?sum/c:0).toFixed(0)}°C · min ${mn.toFixed(0)}°C`);}
}catch(e){}
el.innerHTML=parts.length?parts.map(x=>'<div>'+x+'</div>').join(''):'<div class="empty" style="padding:.3rem">No live stats</div>';
}
async function refreshSystem(){
try{
const r=await api('GET','/api/system/info');
const d=r.dashboard||{};
const subs=r.subsystems||{};
const audio=r.audio||{};
const cam=r.camera||{};
const host=r.host||{};
const dds=r.dds||{};
const url=d.url||('http://'+(d.display_host||'?')+':'+(d.port||'?'));
const audioCur=(audio.current||{});
const audioProf=audioCur.profile?audioCur.profile.label:'(none)';
const camCur=(cam.current||{});
const camDev=camCur.device||{};
const camName=camDev.name||'(no camera)';
document.getElementById('sys-summary').innerHTML=
`<div><strong>URL:</strong> <a href="${esc(url)}" target="_blank" style="color:var(--accent)">${esc(url)}</a></div>`+
`<div><strong>Bound:</strong> ${esc(d.bound_host||'?')}:${d.port||'?'} (iface: <code>${esc(d.interface||'?')}</code>)</div>`+
`<div><strong>Host:</strong> ${esc(host.hostname||'?')} | Python ${esc(host.python||'?')}</div>`+
`<div><strong>Subsystems:</strong> <span style="color:#5fdc8a">${subs.connected||0} connected</span> / <span style="color:#f55">${subs.disconnected||0} not connected</span> (${subs.total||0} total)</div>`+
`<div><strong>DDS interface:</strong> <code>${esc(dds.interface||'?')}</code></div>`+
`<div><strong>Audio profile:</strong> ${esc(audioProf)}</div>`+
`<div style="font-size:.65rem;color:var(--dim);padding-left:.7rem">sink: ${esc(audioCur.sink||'?')}<br>source: ${esc(audioCur.source||'?')}</div>`+
`<div><strong>Camera:</strong> ${esc(camName)}${camDev.serial?' <code style="font-size:.6rem">'+esc(camDev.serial)+'</code>':''}</div>`;
// Network interfaces
const ifaces=(r.network||{}).interfaces||[];
document.getElementById('sys-network').innerHTML=ifaces.length
? '<table>'+ifaces.map(i=>{
const up=i.is_up?'<span style="color:#5fdc8a">●</span>':'<span style="color:#f55">○</span>';
return `<tr><td>${up}</td><td><code>${esc(i.name)}</code></td><td>${esc(i.ip||'-')}</td></tr>`;
}).join('')+'</table>'
: '<div class="empty">No interfaces</div>';
// Subsystem grid
const list=subs.list||[];
document.getElementById('sys-subsystems').innerHTML=list.length
? '<table>'+list.map(s=>{
const dot=s.connected?'<span style="color:#5fdc8a">✓</span>':'<span style="color:#f55">✗</span>';
return `<tr><td>${dot}</td><td>${esc(s.name)}</td></tr>`;
}).join('')+'</table>'
: '<div class="empty">No subsystems reported</div>';
}catch(e){}
}
// Audio
async function refreshAudio(){
try{
const r=await api('GET','/api/audio/status');
const mb=document.getElementById('mic-btn'),sb=document.getElementById('spk-btn');
mb.textContent=r.mic_muted?'Muted':'Unmuted';
mb.className='mute-btn '+(r.mic_muted?'on':'off');
sb.textContent=r.speaker_muted?'Muted':'Unmuted';
sb.className='mute-btn '+(r.speaker_muted?'on':'off');
document.getElementById('mic-badge').style.display=r.mic_muted?'inline-flex':'none';
document.getElementById('spk-badge').style.display=r.speaker_muted?'inline-flex':'none';
// Sync any mute-shortcut buttons spread across other cards.
// Plain-text labels (no emoji) so they render on every browser.
// Effective state: speaker is muted if EITHER pactl sink or G1 is muted.
document.querySelectorAll('.mic-mute-shortcut').forEach(btn=>{
btn.textContent=r.mic_muted?'Mic: MUTED':'Mic: LIVE';
btn.className='btn btn-sm mic-mute-shortcut '+(r.mic_muted?'btn-danger':'btn-success');
btn.title=r.mic_muted?'Microphone is MUTED — click to unmute':'Microphone is LIVE — click to mute';
});
document.querySelectorAll('.spk-mute-shortcut').forEach(btn=>{
// Build a tooltip that shows both paths (pactl + G1) so it's clear
// why the button is red.
const parts=[];
if(r.pulse_sink_muted)parts.push('PulseAudio sink muted');
if(r.g1_speaker_muted)parts.push('G1 speaker volume 0');
const tip=r.speaker_muted
? ('Speaker MUTED ('+(parts.join(', ')||'unknown')+') — click to unmute')
: 'Speaker LIVE — click to mute (hits both PulseAudio and G1 DDS)';
btn.textContent=r.speaker_muted?'Speaker: MUTED':'Speaker: LIVE';
btn.className='btn btn-sm spk-mute-shortcut '+(r.speaker_muted?'btn-danger':'btn-success');
btn.title=tip;
});
// G1 speaker volume slider sync (only if user isn't currently dragging
// it — don't clobber mid-drag input from the user)
const vslider=document.getElementById('g1-vol-slider');
const vlabel=document.getElementById('g1-vol-label');
if(vslider && document.activeElement!==vslider){
const cv=(typeof r.g1_current_volume==='number')?r.g1_current_volume:(typeof r.g1_user_volume==='number'?r.g1_user_volume:100);
vslider.value=cv;
vlabel.textContent=cv+'%';
}
const cur=r.current||{};
const profLabel=cur.profile?cur.profile.label:('('+(cur.source_kind||'manual')+')');
document.getElementById('audio-status-text').innerHTML=
`<strong>${esc(profLabel)}</strong><br>`+
`Sink: ${esc(r.sink||'-')}<br>`+
`Source: ${esc(r.source||'-')}`+
(r.pactl_available?'':'<br><span style="color:#f55">pactl not available</span>')+
(typeof r.g1_current_volume==='number'?`<br>G1 speaker: <strong>${r.g1_current_volume}%</strong> (user pref: ${r.g1_user_volume}%)`:'');
}catch(e){}
}
async function toggleMic(){try{await api('POST','/api/audio/mic/mute');}catch(e){}refreshAudio();}
async function toggleSpeaker(){try{await api('POST','/api/audio/speaker/mute');}catch(e){}refreshAudio();}
// G1 built-in speaker volume (DDS SetVolume). Applies immediately to
// live playback, persists to data/motions/config.json for next restart.
async function setG1Vol(level,b){
if(b)btnLoad(b);
try{
const r=await api('POST','/api/audio/g1-speaker/volume',{level:level});
document.getElementById('g1-vol-slider').value=r.current_volume;
document.getElementById('g1-vol-label').textContent=r.current_volume+'%';
const st=document.getElementById('g1-vol-status');
if(r.muted){
st.textContent='G1 speaker MUTED — will restore to '+r.user_volume+'% on unmute';
st.style.color='#f55';
}else{
st.textContent='G1 volume: '+r.current_volume+'% — saved to config.json';
st.style.color='var(--success)';
}
setTimeout(()=>{st.style.color='var(--dim)';},2500);
toast('G1 volume → '+r.current_volume+'%','ok');
}catch(e){
document.getElementById('g1-vol-status').textContent='Failed: '+(e.message||'unknown');
document.getElementById('g1-vol-status').style.color='#f55';
}
if(b)btnDone(b);
refreshAudio();
}
// Audio device picker
async function scanAudioDevices(b){
btnLoad(b);
try{
await api('POST','/api/audio/apply');
toast('Audio devices scanned','ok');
}catch(e){}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
// SOFT reset — restart pulseaudio/pipewire-pulse. Fixes Pulse-side state.
// Does NOT recover a kernel-side missing USB mic descriptor — for that
// use usbResetAnker.
async function resetAudioSubsystem(b){
if(!confirm('Reset PulseAudio?\n\nThis restarts the audio daemon on the robot.\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nThis fixes stuck PulseAudio state. It does NOT recover a missing\nUSB mic profile — if the Anker mic still does not appear afterwards,\nuse the USB Reset button instead.'))return;
btnLoad(b);
try{
const r=await api('POST','/api/audio/reset');
if(r&&r.ok){
const inOk=r.input_recovered, outOk=r.output_recovered;
if(inOk&&outOk){
toast('Audio subsystem reset · '+(r.flavour||'pulse')+' OK','ok');
}else if(outOk){
toast('Reset done but mic still missing — try USB Reset','err');
}else{
toast('Reset done but no devices detected — check USB','err');
}
}else{
toast('Reset returned no result','err');
}
}catch(e){
toast('Reset failed: '+((e&&e.message)||'unknown'),'err');
}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
// HARD reset — snd-usb-audio unbind+rebind scoped to Anker VID:PID.
// Forces the kernel to re-parse UAC1 descriptors. Needs sudoers entry
// installed once via:
// sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers
async function usbResetAnker(b){
if(!confirm('USB Reset Anker?\n\nThis unbinds and re-binds the snd-usb-audio driver\nfor the Anker dongle, forcing the kernel to re-parse\nthe USB Audio Class descriptors.\n\nUse this when the Anker is plugged but the mic profile\nis missing from the dashboard (PulseAudio shows the sink\nbut no source).\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nIf this fails with "permission denied", run on the robot ONCE:\n sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers'))return;
btnLoad(b);
try{
const r=await api('POST','/api/audio/usb-reset');
if(r&&r.ok){
if(r.input_recovered){
toast('USB reset OK · Anker mic recovered','ok');
}else{
toast('USB reset done but mic not in pactl yet — give it 2s and click Scan','err');
}
}else{
const hint=(r&&r.hint)?(' · '+r.hint):'';
toast('USB reset failed'+hint,'err');
}
}catch(e){
toast('USB reset failed: '+((e&&e.message)||'unknown'),'err');
}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
async function refreshAudioDevices(b){
if(b)btnLoad(b);
try{
const r=await api('GET','/api/audio/devices');
// Profile dropdown
const cur=r.current||{};
const curId=cur.profile?cur.profile.id:'';
const profSel=document.getElementById('audio-profile');
const detectedIds=r.detected_ids||[];
profSel.innerHTML=(r.profiles||[]).map(p=>{
const avail=detectedIds.indexOf(p.id)>=0;
const sel=p.id===curId?' selected':'';
const tag=avail?'':' (not plugged)';
return `<option value="${esc(p.id)}"${sel}${avail?'':' disabled'}>${esc(p.label)}${tag}</option>`;
}).join('');
// Manual sink/source dropdowns
const sinkSel=document.getElementById('audio-sink');
const srcSel=document.getElementById('audio-source');
sinkSel.innerHTML=(r.all_sinks||[]).map(s=>{
const sel=s.name===cur.sink?' selected':'';
return `<option value="${esc(s.name)}"${sel}>${esc(s.description||s.name)}</option>`;
}).join('');
srcSel.innerHTML=(r.all_sources||[]).map(s=>{
const sel=s.name===cur.source?' selected':'';
return `<option value="${esc(s.name)}"${sel}>${esc(s.description||s.name)}</option>`;
}).join('');
// Detected summary
const det=document.getElementById('audio-detected');
if((r.detected||[]).length){
det.innerHTML='Plugged: '+r.detected.map(d=>esc(d.profile.label)).join(', ');
}else{
det.innerHTML=r.pactl_available?'No known profiles plugged':'pactl unavailable';
}
}catch(e){}
if(b)btnDone(b);
refreshAudio();
}
async function selectAudioProfile(profileId){
if(!profileId)return;
try{
await api('POST','/api/audio/select-profile',{profile_id:profileId});
// Auto-apply PulseAudio defaults after switching
await api('POST','/api/audio/apply');
toast('Audio profile switched & applied','ok');
}catch(e){}
refreshAudio();
refreshAudioDevices();
}
async function applyAudioProfile(b){
btnLoad(b);
try{
await api('POST','/api/audio/apply');
toast('Audio applied to PulseAudio','ok');
}catch(e){}
btnDone(b);
refreshAudio();
refreshAudioDevices();
}
async function applyManualAudio(b){
btnLoad(b);
const sink=document.getElementById('audio-sink').value;
const src=document.getElementById('audio-source').value;
try{
await api('POST','/api/audio/select-manual',{sink:sink,source:src});
toast('Manual audio applied','ok');
}catch(e){}
btnDone(b);
refreshAudio();
refreshAudioDevices();
}
// Motion
async function toggleGestural(v){try{await api('POST','/api/motion/gestural-speaking?enabled='+v);}catch(e){}}
let _armBusy=false,_runId=null;
let _selectedAction={sdk:null,jsonl:null};
function selectActionRow(id,kind,name){
_selectedAction[kind]=id;
const listId=kind==='sdk'?'sdk-actions-2':'jsonl-actions-2';
document.querySelectorAll('#'+listId+' .action-row').forEach(el=>{
el.classList.toggle('selected', el.dataset.id===String(id));
});
const btn=document.getElementById('play-'+kind+'-btn');
btn.disabled=_armBusy;
btn.textContent='Play '+name.replace(/_/g,' ');
}
function playSelectedAction(kind){
const id=_selectedAction[kind];
if(id==null||_armBusy)return;
const listId=kind==='sdk'?'sdk-actions-2':'jsonl-actions-2';
const el=document.querySelector('#'+listId+' .action-row.selected');
const name=el?el.querySelector('.r-name').textContent.trim().replace(/ /g,'_'):'';
triggerAction(id,name);
}
function _renderChips(acts){
const sdkEl=document.getElementById('sdk-actions'),jsonlEl=document.getElementById('jsonl-actions');
if(!sdkEl)return;
let sh='',jh='';
for(const a of acts){
const isR=_runId===a.id,cls='action-btn'+(isR?' running':'')+(_armBusy&&!isR?' disabled':''),dis=_armBusy&&!isR?'disabled':'';
const dot=a.file?'<span class="type-dot type-jsonl"></span>':'<span class="type-dot type-sdk"></span>';
const btn=`<button class="${cls}" ${dis} onclick="triggerAction(${a.id},'${esc(a.name)}')" title="${esc(a.file||'SDK')}">${dot}${esc(a.name).replace(/_/g,' ')}</button>`;
if(a.file)jh+=btn;else sh+=btn;
}
sdkEl.innerHTML=sh||'<span class="empty" style="padding:.3rem">No SDK actions</span>';
jsonlEl.innerHTML=jh||'<span class="empty" style="padding:.3rem">No JSONL files</span>';
}
function _renderList(acts){
const sdkEl=document.getElementById('sdk-actions-2'),jsonlEl=document.getElementById('jsonl-actions-2');
if(!sdkEl)return;
const rowFor=(a,kind)=>{
const isR=_runId===a.id,isSel=_selectedAction[kind]===a.id;
const cls='action-row'+(isR?' running':'')+(isSel?' selected':'');
const meta=a.file?esc(a.file):(a.category?esc(a.category):'SDK');
return `<div class="${cls}" data-id="${a.id}" onclick="selectActionRow(${a.id},'${kind}','${esc(a.name)}')" ondblclick="triggerAction(${a.id},'${esc(a.name)}')" title="id=${a.id}">`
+`<span class="type-dot ${a.file?'type-jsonl':'type-sdk'}"></span>`
+`<span class="r-name">${esc(a.name).replace(/_/g,' ')}</span>`
+`<span class="r-meta">${meta} · #${a.id}</span>`
+`</div>`;
};
let sh='',jh='';
for(const a of acts){ if(a.file)jh+=rowFor(a,'jsonl'); else sh+=rowFor(a,'sdk'); }
sdkEl.innerHTML=sh||'<div class="empty">No SDK actions</div>';
jsonlEl.innerHTML=jh||'<div class="empty">No JSONL files</div>';
// keep play-buttons in sync with busy state + whether selection still exists
for(const kind of ['sdk','jsonl']){
const btn=document.getElementById('play-'+kind+'-btn');
if(!btn)continue;
const stillExists=acts.some(a=>a.id===_selectedAction[kind] && (kind==='jsonl'?!!a.file:!a.file));
if(!stillExists){_selectedAction[kind]=null;btn.disabled=true;btn.textContent='Play';}
else{btn.disabled=_armBusy;}
}
}
async function renderActions(arm){
if(!arm)return;
_armBusy=arm.busy||false;
try{
const r=await api('GET','/api/motion/actions');
const acts=r.actions||[];
_renderChips(acts);
_renderList(acts);
['','2'].forEach(sfx=>{const bb=document.getElementById('arm-busy-badge'+sfx);if(bb)bb.style.display=_armBusy?'inline-flex':'none';});
const hdr=document.getElementById('arm-hdr-badge');
if(_armBusy){hdr.style.display='inline-flex';hdr.className='hdr-badge hdr-badge-err';hdr.textContent='ARM BUSY';}
else{hdr.style.display='none';}
}catch(e){}
}
async function triggerAction(id,name){if(_armBusy)return;_runId=id;_armBusy=true;document.getElementById('running-action').textContent='Running: '+name.replace(/_/g,' ')+'...';document.getElementById('running-action').style.display='block';renderActions({busy:true});const speed=parseFloat(document.getElementById('action-speed').value||document.getElementById('action-speed-2').value);try{await api('POST','/api/motion/trigger',{action_id:id,speed});}catch(e){}pollArmBusy();}
async function cancelAction(){try{const r=await api('POST','/api/replay/cancel');toast(r&&r.message?r.message:'Cancelled','info');}catch(e){}_armBusy=false;_runId=null;document.getElementById('running-action').style.display='none';refreshStatus();}
let _armPoll;function pollArmBusy(){clearInterval(_armPoll);_armPoll=setInterval(async()=>{try{const s=await api('GET','/api/replay/status');if(!s.arm?.busy){clearInterval(_armPoll);_armBusy=false;_runId=null;document.getElementById('running-action').style.display='none';refreshStatus();}}catch(e){clearInterval(_armPoll);}},500);}
// Skills
async function refreshSkills(){try{const r=await api('GET','/api/skills/');const el=document.getElementById('skills-list');if(!(r.skills||[]).length){el.innerHTML='<div class="empty">No skills configured</div>';return;}el.innerHTML='<table><tr><th>ID</th><th>Audio</th><th>Motion</th><th>Mode</th><th></th></tr>'+(r.skills||[]).map(s=>`<tr><td>${esc(s.id)}</td><td>${esc(s.audio_file||'--')}</td><td>${esc(s.motion_file||'--')}</td><td>${s.sync_mode}</td><td><button class="btn btn-primary btn-sm" onclick="execSkill('${esc(s.id)}',this)">Run</button></td></tr>`).join('')+'</table>';}catch(e){}}
async function execSkill(id,b){btnLoad(b);try{const r=await api('POST',`/api/skills/${id}/execute`);toast(r.ok?`${id} done (${r.elapsed_sec}s)`:`Failed: ${r.error}`,r.ok?'ok':'err');}catch(e){}btnDone(b);}
// Macros
async function startMacro(b){const n=document.getElementById('macro-name').value;if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/macros/record/start',{name:n});toast('Recording...','ok');document.getElementById('macro-status').textContent='Recording: '+n+'...';}catch(e){}btnDone(b);}
async function stopMacro(b){btnLoad(b);try{const r=await api('POST','/api/macros/record/stop');toast('Saved','ok');document.getElementById('macro-status').textContent=`Saved: ${r.name} (${r.duration_sec}s)`;}catch(e){}btnDone(b);}
async function playMacro(b){const n=document.getElementById('play-macro-name').value;if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/macros/play',{name:n});toast('Played: '+n,'ok');}catch(e){}btnDone(b);}
// Ad-hoc combined playback — select voice + motion, play in parallel.
// Motion list = SDK built-ins + JSONL replays (via /api/motion/actions),
// so the dropdown offers every arm action — not just recorded files.
async function refreshCombo(){
try{
const [av,am]=await Promise.all([
api('GET','/api/macros/audio-files'),
api('GET','/api/motion/actions'),
]);
const voiceSel=document.getElementById('combo-voice');
const motionSel=document.getElementById('combo-motion');
const prevV=voiceSel.value, prevM=motionSel.value;
voiceSel.innerHTML='<option value="">— none —</option>'
+(av.files||[]).map(f=>`<option value="${esc(f.name)}">${esc(f.name)} (${f.size_kb}KB)</option>`).join('');
// Motion: group by category, SDK first then JSONL
const acts=am.actions||[];
const sdk=acts.filter(a=>!a.file);
const jsl=acts.filter(a=>!!a.file);
let html='<option value="">— none —</option>';
if(sdk.length){
html+='<optgroup label="SDK built-ins">';
for(const a of sdk){html+=`<option value="${a.id}">${esc(a.name).replace(/_/g,' ')} (#${a.id})</option>`;}
html+='</optgroup>';
}
if(jsl.length){
html+='<optgroup label="JSONL replays">';
for(const a of jsl){html+=`<option value="${a.id}">${esc(a.file)} (#${a.id})</option>`;}
html+='</optgroup>';
}
motionSel.innerHTML=html;
if(prevV)voiceSel.value=prevV;
if(prevM)motionSel.value=prevM;
}catch(e){toast('Could not load combined lists','err');}
}
async function playCombo(b){
const v=document.getElementById('combo-voice').value;
const mRaw=document.getElementById('combo-motion').value;
const actionId=mRaw?parseInt(mRaw,10):null;
if(!v&&actionId==null)return toast('Pick a voice or motion (or both)','err');
const speed=parseFloat(document.getElementById('combo-speed').value||'1.0');
const st=document.getElementById('combo-status');
const mLabel=mRaw?document.getElementById('combo-motion').selectedOptions[0].textContent:'(no motion)';
st.textContent='Playing: '+[v||'(no voice)',mLabel].join(' + ')+'...';
btnLoad(b);
try{
const r=await api('POST','/api/macros/play-combined',{
audio_file:v,
action_id:actionId,
speed,
});
const parts=[];
if(r.audio_played)parts.push('audio='+r.audio_played);
if(r.motion_played)parts.push('motion='+r.motion_played);
if(r.audio_error)parts.push('audio_err='+r.audio_error);
if(r.motion_error)parts.push('motion_err='+r.motion_error);
st.textContent='Done: '+parts.join(', ');
toast('Combined play done','ok');
}catch(e){st.textContent='Failed';}
btnDone(b);
}
async function stopCombo(b){
const st=document.getElementById('combo-status');
btnLoad(b);
try{
const r=await api('POST','/api/macros/stop-combined');
const parts=[];
if(r.motion_stopped)parts.push('motion stopped');
if(r.audio_stopped)parts.push('audio stopped');
st.textContent='Stopped: '+(parts.join(', ')||'nothing was playing');
toast('Stopped','info');
}catch(e){st.textContent='Stop failed';}
btnDone(b);
}
// Replay
async function refreshReplayFiles(){try{const r=await api('GET','/api/replay/files');const el=document.getElementById('replay-files');if(!(r.files||[]).length){el.innerHTML='<div class="empty">No motion files</div>';return;}el.innerHTML='<table><tr><th>File</th><th>Frames</th><th>Duration</th><th>Size</th><th></th></tr>'+(r.files||[]).map(f=>`<tr><td>${esc(f.name)}</td><td>${f.frames}</td><td>${f.duration_sec}s</td><td>${f.size_kb}KB</td><td><button class="btn btn-primary btn-sm" onclick="document.getElementById('replay-name').value='${esc(f.name)}';testReplay()">Play</button> <button class="btn btn-danger btn-sm" onclick="deleteMotionFile('${esc(f.name)}')">Del</button></td></tr>`).join('')+'</table>';}catch(e){}}
async function testReplay(b){const n=document.getElementById('replay-name').value,s=parseFloat(document.getElementById('replay-speed').value);if(!n)return;btnLoad(b);try{await api('POST','/api/replay/test',{name:n,speed:s});toast('Replay: '+n,'ok');pollArmBusy();}catch(e){}btnDone(b);}
async function cancelReplay(){try{const r=await api('POST','/api/replay/cancel');toast(r&&r.message?r.message:'Cancelled','info');}catch(e){}}
async function deleteMotionFile(n){if(confirm('Delete '+n+'?'))try{await api('DELETE','/api/replay/files/'+encodeURIComponent(n));toast('Deleted','ok');refreshReplayFiles();}catch(e){}}
async function uploadMotionFile(input){if(!input.files[0])return;const fd=new FormData();fd.append('file',input.files[0]);try{const r=await fetch('/api/replay/files/upload',{method:'POST',body:fd});if(!r.ok){const j=await r.json();toast(j.detail||'Upload failed','err');}else{toast('Uploaded','ok');refreshReplayFiles();}}catch(e){toast('Upload error','err');}input.value='';}
async function startTeaching(b){const n=document.getElementById('teach-name').value,d=parseFloat(document.getElementById('teach-duration').value);if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/replay/teach/start',{name:n,duration_sec:d});toast('Teaching: '+n,'ok');pollTeachStatus();}catch(e){}btnDone(b);}
async function stopTeaching(b){btnLoad(b);try{const r=await api('POST','/api/replay/teach/stop');toast(`Saved: ${r.name} (${r.frames} frames)`,'ok');document.getElementById('teach-status').textContent=`Done: ${r.frames} frames`;refreshReplayFiles();}catch(e){}btnDone(b);}
let _teachPoll;function pollTeachStatus(){clearInterval(_teachPoll);_teachPoll=setInterval(async()=>{try{const r=await api('GET','/api/replay/teach/status');document.getElementById('teach-status').textContent=`${r.phase} | ${r.elapsed_sec}s | ${r.frames_recorded} frames`;if(!r.recording){clearInterval(_teachPoll);refreshReplayFiles();}}catch(e){clearInterval(_teachPoll);}},500);}
// Scripts
async function refreshScripts(){try{const r=await api('GET','/api/scripts/');const sel=document.getElementById('script-select');const keep=sel.value;sel.innerHTML='<option value="">-- select --</option>'+(r.files||[]).map(f=>{const tag=f.active?' ● active':(f.is_default?' (default)':'');return `<option value="${esc(f.name)}">${esc(f.name)}${tag} (${f.size_bytes}B)</option>`;}).join('');if(keep)sel.value=keep;const a=document.getElementById('script-active');if(a)a.textContent=(r.active||r.default||'sanad_script.txt')+((r.active&&r.active===r.default)?' (default)':'');}catch(e){}}
async function useForGemini(){const name=document.getElementById('script-select').value;if(!name)return toast('Select a script first','err');const restart=confirm('Set "'+name+'" as Geminis persona.\n\nRestart the voice session NOW so it takes effect immediately?\n(Cancel = applies on the next voice restart.)');try{const r=await api('POST','/api/scripts/active',{name,restart});toast('Gemini persona → '+r.active+(r.restarted?' (voice restarting)':' (applies on next restart)'),'ok');refreshScripts();setTimeout(refreshLiveSub,3000);}catch(e){toast('Failed: '+(e.message||e),'err');}}
async function resetPersona(){const restart=confirm('Reset Gemini persona to the default (sanad_script.txt).\n\nRestart the voice session now to apply?');try{const r=await api('POST','/api/scripts/active',{name:null,restart});toast('Gemini persona → '+r.active,'ok');refreshScripts();setTimeout(refreshLiveSub,3000);}catch(e){toast('Failed: '+(e.message||e),'err');}}
async function refreshStorage(){try{const r=await api('GET','/api/system/storage');const sum=document.getElementById('storage-summary');if(sum)sum.textContent='data '+r.data_human+' · logs '+r.logs_human+((r.disk&&r.disk.free_human)?(' · disk free '+r.disk.free_human+' ('+r.disk.used_pct+'% used)'):'');const el=document.getElementById('storage-list');if(!el)return;el.innerHTML=(r.categories||[]).map(c=>{const act=c.cleanable?`<button class="btn btn-danger btn-sm" onclick="cleanStorage('${c.key}')">Clean</button>`:'<span class="r-meta" style="color:var(--dim)">kept</span>';return `<div class="action-row"><span class="r-name">${esc(c.label)}</span><span class="r-meta">${esc(c.size_human)} · ${c.files} files</span><span style="margin-left:auto">${act}</span></div>`;}).join('');}catch(e){const el=document.getElementById('storage-list');if(el)el.textContent='ERR: '+(e.message||e);}}
async function cleanStorage(target){const label=target==='all'?'ALL disposable data (recordings + named records + logs)':target;if(!confirm('Clean '+label+'?\n\nRecordings & named records are permanently deleted; logs are cleared. Faces/motions/zones are NOT touched.'))return;try{const r=await api('POST','/api/system/storage/clean',{target});toast('Freed '+(r.total_freed_human||'0'),'ok');refreshStorage();}catch(e){toast('Clean failed: '+(e.message||e),'err');}}
async function loadScript(name){if(!name)return;try{const r=await api('POST','/api/scripts/load',{name});document.getElementById('script-content').value=r.content||'';}catch(e){}}
async function saveScript(){const name=document.getElementById('script-select').value,content=document.getElementById('script-content').value;if(!name)return toast('Select file','err');try{await api('POST','/api/scripts/save',{name,content});toast('Saved','ok');refreshScripts();}catch(e){}}
async function createScript(){const name=document.getElementById('script-new-name').value,content=document.getElementById('script-content').value;if(!name)return toast('Enter filename','err');try{await api('POST','/api/scripts/create',{name,content});toast('Created: '+name,'ok');refreshScripts();}catch(e){}}
async function deleteScript(){const name=document.getElementById('script-select').value;if(!name)return;if(confirm('Delete '+name+'?'))try{await api('POST','/api/scripts/delete',{name});toast('Deleted','ok');document.getElementById('script-content').value='';refreshScripts();}catch(e){}}
// Prompt
// Prompt Management edits the PERSONA (sanad_script.txt). GET/update/reload all
// operate on the script — the rule file (sanad_rule.txt) is edited via Scripts
// Manager, not here, so we no longer advertise it (it confused "which file").
async function refreshPrompt(){try{const r=await api('GET','/api/prompt/');document.getElementById('prompt-content').value=r.system_prompt||'';document.getElementById('prompt-info').textContent=`Editing: ${r.script_path}`;}catch(e){}}
async function updatePrompt(){try{await api('POST','/api/prompt/update',{content:document.getElementById('prompt-content').value});toast('Saved','ok');}catch(e){}}
async function reloadPrompt(){try{const r=await api('GET','/api/prompt/');document.getElementById('prompt-content').value=r.system_prompt||'';document.getElementById('prompt-info').textContent=`Editing: ${r.script_path}`;toast('Reloaded','ok');}catch(e){}}
// Records
// ── WhatsApp-style voice-message records ──────────────────────────
const REC_WAVE_BARS=34;
let _recDur={}; // record_name -> duration_sec (for idle cards)
let _pb=null; // last playback snapshot {playing,record_name,position_sec,duration_sec,paused,at}
function fmtTime(s){s=Math.max(0,Math.floor(s||0));return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');}
function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
function waveBars(name){ // deterministic pseudo-waveform (FNV-ish hash) so each record has a stable, distinct look
let h=2166136261;for(let i=0;i<name.length;i++){h=(h^name.charCodeAt(i))>>>0;h=Math.imul(h,16777619)>>>0;}
let out='';for(let i=0;i<REC_WAVE_BARS;i++){h=(Math.imul(h,1103515245)+12345)>>>0;out+=`<span class="wb" style="height:${22+((h>>>8)%78)}%"></span>`;}
return out;
}
function selectedRecs(){return Array.from(document.querySelectorAll('.rec-sel:checked')).map(c=>c.dataset.rec);}
function updateSelCount(){const n=selectedRecs().length;const b=document.getElementById('rec-del-selected');if(b){b.textContent='Delete selected ('+n+')';b.disabled=n===0;}const all=document.getElementById('rec-select-all');const total=document.querySelectorAll('.rec-sel').length;if(all)all.checked=total>0&&n===total;}
function recSelectAll(cb){document.querySelectorAll('.rec-sel').forEach(c=>{c.checked=cb.checked;});updateSelCount();}
async function refreshRecords(){try{
const r=await api('GET','/api/records/');
const el=document.getElementById('records-list');
const recs=r.records||[];
const tot=document.getElementById('rec-total');if(tot)tot.textContent=recs.length?('Total: '+recs.length):'';
if(!recs.length){el.innerHTML='<div class="empty">No records saved</div>';_recDur={};updateSelCount();return;}
_recDur={};
el.innerHTML=recs.map(rec=>{
const raw=rec.record_name, ratt=String(raw).replace(/"/g,'&quot;');
const f=(rec.files&&(rec.files.speaker_recording||rec.files.gemini_raw_output))||{};
const dur=+(f.duration_seconds||0);_recDur[raw]=dur;
return `<div class="rec-card" data-rec="${ratt}">
<div class="rec-row">
<input type="checkbox" class="rec-sel" data-rec="${ratt}" onchange="updateSelCount()" title="Select for delete">
<button class="rec-play" title="Play / Pause" onclick="recToggle('${jsq(raw)}')">▶</button>
<button class="rec-replay" title="Replay from start" onclick="recReplay('${jsq(raw)}')">⟲</button>
<div class="rec-wave" title="Click or drag to seek" onpointerdown="recWaveDown(event,'${jsq(raw)}')">${waveBars(String(raw))}</div>
<span class="rec-time">0:00 / ${fmtTime(dur)}</span>
<span class="rec-acts">
<button class="btn btn-ghost btn-sm" title="Play the raw Gemini TTS file" onclick="playRecord('${jsq(raw)}','raw')">Raw</button>
<button class="btn btn-danger btn-sm" onclick="deleteRecord('${jsq(raw)}')">Del</button>
</span>
</div>
<div class="rec-text">${esc(rec.text||'(no text)')}</div>
</div>`;
}).join('');
updateSelCount();
refreshPlaybackStatus();
}catch(e){}}
// Per-card play button: play this clip, or pause/resume if it's the one playing.
async function recToggle(name){
if(_pb && _pb.playing && _pb.record_name===name){
try{ await api('POST',_pb.paused?'/api/records/resume':'/api/records/pause'); }catch(e){}
refreshPlaybackStatus();
}else{
playRecord(name,'speaker');
}
}
let _scrub=null; // active waveform scrub: {name,wave,dur,frac}
// Replay from the start: seek to 0 (resume if paused), or just play it.
async function recReplay(name){
if(_pb && _pb.playing && _pb.record_name===name){
try{await api('POST','/api/records/seek?position_sec=0');}catch(e){}
if(_pb.paused){try{await api('POST','/api/records/resume');}catch(e){}}
refreshPlaybackStatus();
}else{
playRecord(name,'speaker');
}
}
// Drag/click the waveform to scrub. Preview the fill live; commit on release.
function recWaveDown(ev,name){
const dur=_recDur[name]||0;if(dur<=0)return;
ev.preventDefault();
const wave=ev.currentTarget;
const fracAt=(x)=>{const r=wave.getBoundingClientRect();return Math.max(0,Math.min(1,(x-r.left)/(r.width||1)));};
const paint=(frac)=>{
const card=wave.closest('.rec-card');
const bars=wave.querySelectorAll('.wb');const k=Math.round(frac*bars.length);
bars.forEach((b,i)=>b.classList.toggle('played',i<k));
const t=card&&card.querySelector('.rec-time');if(t)t.textContent=fmtTime(frac*dur)+' / '+fmtTime(dur);
};
_scrub={name,wave,dur,frac:fracAt(ev.clientX)};
paint(_scrub.frac);
const move=(e)=>{if(!_scrub)return;_scrub.frac=fracAt(e.clientX);paint(_scrub.frac);};
const up=async()=>{
document.removeEventListener('pointermove',move);
document.removeEventListener('pointerup',up);
const s=_scrub;_scrub=null;if(!s)return;
await commitSeek(s.name,s.frac*s.dur);
};
document.addEventListener('pointermove',move);
document.addEventListener('pointerup',up);
}
async function commitSeek(name,posSec){
if(_pb && _pb.playing && _pb.record_name===name){
try{await api('POST','/api/records/seek?position_sec='+posSec.toFixed(2));}catch(e){}
refreshPlaybackStatus();
}else{
// not the live clip → start it, then jump once it's actually playing
playRecord(name,'speaker');
setTimeout(async()=>{try{await api('POST','/api/records/seek?position_sec='+posSec.toFixed(2));}catch(e){}refreshPlaybackStatus();},450);
}
}
async function deleteSelectedRecords(){
const names=selectedRecs();if(!names.length)return;
if(!confirm('Delete '+names.length+' selected record(s)? This cannot be undone.'))return;
try{const r=await api('POST','/api/records/delete-bulk',{record_names:names});toast('Deleted '+(r.deleted_count||0)+' record(s)','ok');refreshRecords();}
catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
async function deleteAllRecords(){
if(!confirm('Delete ALL saved records? This cannot be undone.'))return;
try{const r=await api('POST','/api/records/delete-bulk',{all:true});toast('Deleted all ('+(r.deleted_count||0)+')','ok');refreshRecords();}
catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
function playRecord(name,kind){
// No queue, no debounce: every press fires immediately and the backend
// PREEMPTS — pressing a record interrupts whatever's playing and starts that
// one right away. No per-click toast (the ▶ playing bar shows the current).
api('POST','/api/records/play',{record_name:name,file_kind:kind})
.then(()=>{refreshPlaybackStatus();setTimeout(refreshPlaybackStatus,250);})
.catch(e=>toast('Play failed: '+(e.message||e),'err'));
}
async function deleteRecord(name){if(confirm('Delete '+name+'?'))try{await api('POST','/api/records/delete',{record_name:name});toast('Deleted','ok');refreshRecords();}catch(e){}}
// Saved-record playback controls — operate on the active G1 playback
// (one at a time). The Pause/Resume buttons swap in refreshPlaybackStatus
// based on what audio_mgr reports; the bar hides itself when nothing plays.
async function pauseRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/pause');refreshPlaybackStatus();}
catch(e){toast('Pause failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
async function resumeRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/resume');refreshPlaybackStatus();}
catch(e){toast('Resume failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
async function stopRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/stop');toast('Stopped','info');refreshPlaybackStatus();}
catch(e){toast('Stop failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
// Live-Gemini pause HOLD: checked = stay paused until unchecked; unchecked = auto.
async function toggleLiveHold(cb){
await setLiveHold(cb.checked);
}
// Toggle from the LIVE GEMINI PROCESS panel badge.
async function toggleLiveHoldBadge(){
const pm=document.getElementById('ls-pausemode');
const held=pm && pm.textContent.indexOf('Manual')>=0;
await setLiveHold(!held);
}
async function setLiveHold(on){
try{
const r=await api('POST','/api/records/live-hold?on='+(on?'true':'false'));
updateLiveHoldUI(!!r.live_hold);
toast(r.live_hold?'Gemini held paused (manual)':'Gemini auto (resumes after clips)','info');
}catch(e){updateLiveHoldUI(!on);toast('Hold toggle failed','err');}
}
function updateLiveHoldUI(hold){
const cb=document.getElementById('rec-live-hold');
const st=document.getElementById('rec-live-hold-state');
if(cb && cb.checked!==hold) cb.checked=hold;
if(st){
st.textContent=hold?'Paused — stays paused until you uncheck':'Auto — resumes after each clip';
st.style.color=hold?'#ffb454':'var(--dim)';
}
// Keep the LIVE GEMINI PROCESS panel badge in sync.
const pm=document.getElementById('ls-pausemode');
if(pm){
pm.textContent=hold?'Pause: Manual':'Pause: Auto';
pm.className='badge '+(hold?'badge-warn':'badge-ok');
}
}
async function refreshPlaybackStatus(){
try{
const s=await api('GET','/api/records/playback-status');
updateLiveHoldUI(!!s.live_hold);
_pb = s.playing ? {playing:true,record_name:s.record_name,
position_sec:s.position_sec||0,duration_sec:s.duration_sec||0,
paused:!!s.paused,at:Date.now()} : null;
applyPlaybackToCards();
}catch(e){}
}
// Full pass over every card on the 1s poll: marks the playing one, resets the
// rest. The 120ms tickPlayback only animates the playing card's wave/time.
function applyPlaybackToCards(){
document.querySelectorAll('.rec-card').forEach(card=>{
const name=card.dataset.rec;
if(_scrub && _scrub.name===name) return; // don't fight an active scrub
const playing=!!(_pb && _pb.record_name===name);
const dur=playing?(_pb.duration_sec||_recDur[name]||0):(_recDur[name]||0);
let pos=0;
if(playing){
pos=_pb.position_sec||0;
if(!_pb.paused) pos=Math.min(dur,pos+(Date.now()-_pb.at)/1000);
}
card.classList.toggle('is-playing',playing);
const btn=card.querySelector('.rec-play');
if(btn) btn.textContent=(playing && !_pb.paused)?'⏸':'▶';
const time=card.querySelector('.rec-time');
if(time) time.textContent=fmtTime(pos)+' / '+fmtTime(dur);
const bars=card.querySelectorAll('.rec-wave .wb');
const k=Math.round((dur>0?Math.min(1,pos/dur):0)*bars.length);
bars.forEach((b,i)=>b.classList.toggle('played',playing && i<k));
});
}
// Smooth progress between polls — only touches the one playing card.
function tickPlayback(){
if(_scrub) return; // a scrub preview is authoritative while dragging
if(!(_pb && _pb.playing && !_pb.paused)) return;
const card=[...document.querySelectorAll('.rec-card')].find(c=>c.dataset.rec===_pb.record_name);
if(!card) return;
const dur=_pb.duration_sec||_recDur[_pb.record_name]||0;
const pos=Math.min(dur,(_pb.position_sec||0)+(Date.now()-_pb.at)/1000);
const time=card.querySelector('.rec-time');
if(time) time.textContent=fmtTime(pos)+' / '+fmtTime(dur);
const bars=card.querySelectorAll('.rec-wave .wb');
const k=Math.round((dur>0?pos/dur:0)*bars.length);
bars.forEach((b,i)=>b.classList.toggle('played',i<k));
}
// Live Voice
async function startLiveVoice(b){
btnLoad(b);
try{
const r=await api('POST','/api/live-voice/start');
if(r.started){
toast('Live voice started','ok');
}else{
toast('Live voice failed: '+(r.error||r.message||'unknown'),'err');
}
}catch(e){}
btnDone(b);
refreshLiveVoice();
}
async function stopLiveVoice(b){btnLoad(b);try{await api('POST','/api/live-voice/stop');toast('Stopped','info');}catch(e){}btnDone(b);refreshLiveVoice();}
async function setDeferredMode(v){try{await api('POST','/api/live-voice/deferred-mode?enabled='+v);}catch(e){}}
async function setTriggerEnabled(v){try{await api('POST','/api/live-voice/trigger-enabled?enabled='+v);}catch(e){}}
async function refreshLiveVoice(){
try{
const r=await api('GET','/api/live-voice/status');
const st=document.getElementById('lv-state');
const running = r.running===true;
st.textContent = running ? ('active ('+(r.dispatch_actions||0)+' actions)') : 'idle';
st.className='badge '+(running?'badge-ok':'badge-warn');
document.getElementById('lv-last-text').textContent=r.last_heard||'--';
document.getElementById('lv-pending').textContent=r.pending_action||r.last_action||'--';
document.getElementById('lv-deferred').checked=r.deferred_mode===true;
document.getElementById('lv-trigger-enabled').checked=r.trigger_enabled===true;
document.getElementById('lv-audio').textContent=r.audio_attached?'yes':'no';
document.getElementById('lv-arm').textContent=r.arm_attached?'yes':'no';
document.getElementById('lv-gem').textContent=r.gemini_connected?'connected':'disconnected';
// Render arm trigger log — one line per fire
const triggers=r.triggers||[];
const lines=triggers.map(t=>{
const mode=t.mode==='deferred'?'[defer]':'[now]';
return `[${t.time}] ${mode} ${t.action_name} (id=${t.action_id}) ← "${t.user_text}"`;
});
document.getElementById('lv-transcript').textContent=lines.join('\n')||'(waiting for voice triggers...)';
document.getElementById('lv-error').textContent='';
}catch(e){}
}
// Live Subprocess
async function startLiveSub(b){btnLoad(b);try{await api('POST','/api/live-subprocess/start');toast('Started','ok');setTimeout(reassertLiveHold,2500);}catch(e){}btnDone(b);refreshLiveSub();}
// A fresh child resets its pause flag; if Manual hold is on, re-apply it so the
// badge stays truthful and Gemini stays paused after a restart.
async function reassertLiveHold(){try{const s=await api('GET','/api/records/playback-status');if(s.live_hold)await api('POST','/api/records/live-hold?on=true');}catch(e){}}
async function stopLiveSub(b){btnLoad(b);try{await api('POST','/api/live-subprocess/stop');toast('Stopped','info');}catch(e){}btnDone(b);refreshLiveSub();}
async function refreshLiveSub(){try{const r=await api('GET','/api/live-subprocess/status');const st=document.getElementById('ls-state');st.textContent=r.state||'stopped';st.className='badge '+(r.running?'badge-ok':'badge-warn');document.getElementById('ls-msg').textContent=r.state_message||'--';document.getElementById('ls-user').textContent=r.last_user_text||'--';document.getElementById('ls-log').textContent=(r.log_tail||[]).slice(-25).join('\n');const rb=document.getElementById('ls-rec-btn');if(rb){const on=!!r.record_enabled;rb.textContent='Rec: '+(on?'ON':'OFF');rb.className='btn btn-sm '+(on?'btn-success':'btn-ghost');}}catch(e){}}
async function toggleAutoRecord(b){const cur=(b&&b.textContent||'').includes('ON');const next=!cur;btnLoad(b);try{const r=await api('POST','/api/live-subprocess/record?on='+(next?'1':'0'));toast('Auto-recording '+(r.record_enabled?'ON':'OFF'),'ok');}catch(e){toast('Toggle failed: '+(e.message||e),'err');}btnDone(b);refreshLiveSub();}
// Typed Replay
async function trGenerate(b){const t=document.getElementById('tr-text').value;if(!t)return toast('Enter text','err');btnLoad(b);try{await api('POST','/api/typed-replay/say',{text:t,record:document.getElementById('tr-capture').checked,record_name:document.getElementById('tr-name').value});toast('Generated & played','ok');refreshTR();}catch(e){}btnDone(b);}
async function trReplayLast(b){btnLoad(b);try{await api('POST','/api/typed-replay/replay-last');toast('Replayed','ok');refreshTR();}catch(e){}btnDone(b);}
async function trSaveLast(b){btnLoad(b);try{await api('POST','/api/typed-replay/save-last',{record_name:document.getElementById('tr-name').value});toast('Saved','ok');refreshTR();refreshRecords();}catch(e){}btnDone(b);}
async function refreshTR(){try{const r=await api('GET','/api/typed-replay/status');const s=r.session||{};document.getElementById('tr-session').innerHTML=`<strong>Text:</strong> ${esc(s.text||'--')}<br><strong>Audio:</strong> ${s.has_audio?'Yes':'No'} | <strong>Capture:</strong> ${s.has_capture?'Yes':'No'}<br><strong>Replays:</strong> ${s.replay_count||0}<br><strong>Generated:</strong> ${s.generated_at||'--'}<br><strong>Saved:</strong> ${esc(s.saved_as||'--')}`;}catch(e){}}
// Wake Phrases
async function refreshWakeActions(){try{const r=await api('GET','/api/wake-phrases/');const sel=document.getElementById('wp-action');sel.innerHTML='<option value="">-- select action --</option>'+(r.actions||[]).map(a=>`<option value="${esc(a.action)}">${esc(a.action)} (${a.phrase_count})</option>`).join('');}catch(e){}}
async function loadWakePhrases(action){if(!action)return;try{const r=await api('GET',`/api/wake-phrases/${encodeURIComponent(action)}`);const el=document.getElementById('wp-phrases');if(!(r.phrases||[]).length){el.innerHTML='<div class="empty">No phrases</div>';return;}el.innerHTML=(r.phrases||[]).map(p=>`<div class="row"><span style="flex:1;font-size:.78rem">${esc(p)}</span><button class="btn btn-danger btn-sm" onclick="removeWakePhrase(document.getElementById('wp-action').value,this.dataset.p)" data-p="${esc(p)}">X</button></div>`).join('');}catch(e){}}
async function addWakePhrase(){const action=document.getElementById('wp-action').value,phrase=document.getElementById('wp-new').value;if(!action||!phrase)return toast('Select action & enter phrase','err');try{await api('POST','/api/wake-phrases/add',{action,phrase});toast('Added','ok');document.getElementById('wp-new').value='';loadWakePhrases(action);}catch(e){}}
async function removeWakePhrase(action,phrase){try{await api('POST','/api/wake-phrases/remove',{action,phrase});toast('Removed','ok');loadWakePhrases(action);}catch(e){}}
// Log Snapshot
async function saveLogSnapshot(){try{const r=await api('POST','/api/logs/snapshot');toast(`Snapshot saved (${r.snapshots?.length||0} files)`,'ok');}catch(e){}}
// Logs — copy + download
async function _fetchLogBundle(lines){
const url='/api/logs/bundle'+(lines?('?lines='+lines):'');
const r=await fetch(url);
if(!r.ok){toast('Bundle fetch failed: HTTP '+r.status,'err');throw new Error('bundle fetch failed');}
return await r.text();
}
async function _copyToClipboard(text){
// Prefer the modern Clipboard API (https or localhost)
if(navigator.clipboard&&window.isSecureContext){
try{await navigator.clipboard.writeText(text);return true;}catch(e){/* fall through */}
}
// Fallback: hidden textarea + execCommand (works on http://wlan-ip:8000)
try{
const ta=document.createElement('textarea');
ta.value=text;
ta.setAttribute('readonly','');
ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='0';
document.body.appendChild(ta);
ta.focus();ta.select();
const ok=document.execCommand('copy');
document.body.removeChild(ta);
return ok;
}catch(e){return false;}
}
async function copyAllLogs(b){
if(b)btnLoad(b);
try{
toast('Fetching log bundle...','info');
const text=await _fetchLogBundle(1000);
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
if(ok){
toast(`Copied ${kb} KB to clipboard`,'ok');
}else{
toast('Clipboard unavailable — use Download instead','err');
}
}catch(e){}
if(b)btnDone(b);
}
async function copyVisibleLogs(b){
if(b)btnLoad(b);
const text=document.getElementById('log-box').textContent||'';
if(!text.trim()){toast('Log box is empty','info');if(b)btnDone(b);return;}
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
toast(ok?`Copied ${kb} KB to clipboard`:'Clipboard unavailable','ok');
if(b)btnDone(b);
}
// Generic — copy whatever is inside the element with the given id.
// Used by the small "Copy" button on every log-box card in the dashboard.
async function copyLogBox(elId, b){
if(b)btnLoad(b);
const el=document.getElementById(elId);
const text=(el?.textContent||'').trim();
if(!text){toast('Nothing to copy','info');if(b)btnDone(b);return;}
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
toast(ok?`Copied ${kb} KB`:'Clipboard unavailable',ok?'ok':'err');
if(b)btnDone(b);
}
function downloadLogBundle(){
// Browser directly downloads the /bundle endpoint — no intermediate JS
const ts=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19);
const a=document.createElement('a');
a.href='/api/logs/bundle?lines=5000';
a.download=`sanad_bundle_${ts}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast('Downloading bundle...','info');
}
// Status
async function refreshStatus(){try{const s=await api('GET','/api/status');document.getElementById('status-dot').className='dot dot-ok';document.getElementById('status-text').textContent='Online';document.getElementById('gestural').checked=s.brain?.gestural_speaking||false;renderActions(s.arm);const gb=document.getElementById('gemini-badge');if(s.voice?.connected){gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-ok';gb.textContent='GEMINI';}else{gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-err';gb.textContent='GEMINI OFF';}}catch(e){document.getElementById('status-dot').className='dot dot-err';document.getElementById('status-text').textContent='Offline';}}
// WebSocket logs
let logWs;function connectLogs(){const p=location.protocol==='https:'?'wss':'ws';logWs=new WebSocket(`${p}://${location.host}/ws/logs`);const box=document.getElementById('log-box');logWs.onmessage=e=>{box.textContent+=e.data+'\n';if(box.childNodes.length>1000)box.textContent=box.textContent.split('\n').slice(-500).join('\n');box.scrollTop=box.scrollHeight;};logWs.onclose=()=>setTimeout(connectLogs,3000);}
// Auto-connect Gemini and auto-start Live Subprocess on page load
async function autoConnectGemini(){
try{
const r=await api('GET','/api/voice/status');
if(r.gemini&&!r.gemini.connected){
// Fire and forget — don't block page load (Gemini connect can take 10-30s)
fetch(API+'/api/voice/connect',{method:'POST',headers:{'Content-Type':'application/json'}})
.then(r=>r.json()).then(r=>{if(r.connected){toast('Gemini connected','ok');refreshStatus();}})
.catch(()=>{});
}
}catch(e){}
}
async function autoStartLiveSub(){
try{
const r=await api('GET','/api/live-subprocess/status');
if(!r.running&&r.available!==false){
await api('POST','/api/live-subprocess/start');
toast('Live Gemini process auto-started','ok');
refreshLiveSub();
}
}catch(e){}
}
// ── Recognition tab (camera vision + face recognition) ──
// Mirror of /api/recognition/state.vision_enabled — kept fresh by
// refreshRecognition() so the Live-Gemini-panel Camera button can flip
// it without a round-trip GET.
let _recVisionEnabled=false;
async function refreshRecognition(){
try{
const r=await api('GET','/api/recognition/state');
_recVisionEnabled=!!r.vision_enabled;
const v=document.getElementById('rec-vision-toggle');
const f=document.getElementById('rec-facerec-toggle');
if(v) v.checked=!!r.vision_enabled;
if(f) f.checked=!!r.face_rec_enabled;
const cs=document.getElementById('rec-camera-status');
if(cs){
const c=r.camera||{};
cs.title=c.error||'';
if(c.running&&c.backend){
cs.textContent=c.backend+' '+(c.width||'')+'x'+(c.height||'')
+(c.reconnect_count?(' ↻'+c.reconnect_count):'');
cs.className='badge badge-ok';
}else if(c.running&&!c.backend){
// thread alive but between reconnect attempts (camera unplugged)
cs.textContent='reconnecting…';cs.className='badge badge-warn';
}else if(c.error){
cs.textContent='error';cs.className='badge badge-warn';
}else{
cs.textContent='off';cs.className='badge';
}
}
// Camera button in the Live Gemini Process panel (Voice & Audio tab) —
// same toggle as the Recognition tab, surfaced where it's handy.
const cb=document.getElementById('ls-cam-btn');
if(cb){
const c=r.camera||{};
if(c.running&&c.backend){
cb.textContent='Camera: ON';
cb.className='btn btn-sm btn-success';
cb.title='Streaming '+(c.backend||'')+' '+(c.width||'')+'x'+(c.height||'')+' to Gemini — click to turn off';
}else if(c.running&&!c.backend){
cb.textContent='Camera: …';
cb.className='btn btn-sm btn-ghost';
cb.title='Camera reconnecting…';
}else if(r.vision_enabled&&c.error){
cb.textContent='Camera: N/A';
cb.className='btn btn-sm btn-danger';
cb.title='Vision on but no camera backend: '+(c.error||'');
}else{
cb.textContent='Camera: OFF';
cb.className='btn btn-sm btn-ghost';
cb.title='Click to stream camera frames to Gemini Live';
}
}
const fs=document.getElementById('rec-facerec-status');
if(fs){fs.textContent=r.face_rec_enabled?'on':'off';fs.className='badge '+(r.face_rec_enabled?'badge-ok':'');}
const fc=document.getElementById('rec-faces-count');
if(fc) fc.textContent=`(${r.faces_count} faces, ${r.photos_count} photos)`;
const gv=document.getElementById('rec-gallery-version');
if(gv) gv.textContent='v.'+r.gallery_version;
// toggle preview visibility — only when actively capturing (has a backend)
const img=document.getElementById('rec-preview-img');
const empty=document.getElementById('rec-preview-empty');
const meta=document.getElementById('rec-preview-meta');
const c2=r.camera||{};
if(c2.running&&c2.backend){
img.style.display='inline-block';empty.style.display='none';
if(meta) meta.textContent=`${c2.width}x${c2.height} @ ${c2.fps}fps · seq=${c2.frame_seq}`;
}else{
img.style.display='none';empty.style.display='block';
if(empty) empty.textContent=(c2.running&&!c2.backend)
? 'Camera reconnecting…'
: 'Camera off — toggle Vision ON to see the live feed.';
if(meta) meta.textContent='--';
}
// Highlight the active resolution / quality buttons to match the live
// capture profile (works whether the camera is running or idle).
document.querySelectorAll('#rec-res-buttons button').forEach(btn=>{
const on = parseInt(btn.dataset.w)===c2.width
&& parseInt(btn.dataset.h)===c2.height
&& parseInt(btn.dataset.fps)===c2.fps;
btn.className='btn btn-sm '+(on?'btn-primary':'btn-ghost');
});
document.querySelectorAll('#rec-quality-buttons button').forEach(btn=>{
const on = parseInt(btn.dataset.q)===c2.jpeg_quality;
btn.className='btn btn-sm '+(on?'btn-primary':'btn-ghost');
});
}catch(e){}
}
// Resolution / FPS button menu — each click POSTs one mode and the
// CameraDaemon rebuilds the pipeline at it. refreshRecognition() then
// highlights whichever button matches the live profile.
async function setCameraMode(btn){
btnLoad(btn);
try{
const body={
width: parseInt(btn.dataset.w),
height: parseInt(btn.dataset.h),
fps: parseInt(btn.dataset.fps),
};
const r=await api('POST','/api/recognition/camera-config',body);
const p=r.profile||body;
toast(`Camera → ${p.width}×${p.height} @ ${p.fps}fps`,'ok');
refreshRecognition();
}catch(e){toast('Resolution change failed: '+(e.message||e),'err');}
btnDone(btn);
}
async function setCameraQuality(btn){
btnLoad(btn);
try{
const q=parseInt(btn.dataset.q);
await api('POST','/api/recognition/camera-config',{jpeg_quality:q});
toast('JPEG quality → '+q,'ok');
refreshRecognition();
}catch(e){toast('Quality change failed: '+(e.message||e),'err');}
btnDone(btn);
}
// Camera button in the Live Gemini Process panel — flips the same
// vision toggle the Recognition tab owns. _recVisionEnabled is the
// last-known state (refreshed every 5 s by refreshRecognition).
async function toggleGeminiCamera(b){
if(b) btnLoad(b);
const next=!_recVisionEnabled;
try{
const r=await api('POST','/api/recognition/vision?on='+(next?'1':'0'));
_recVisionEnabled=!!(r&&r.vision_enabled);
toast(next?'Camera ON for Gemini':'Camera OFF for Gemini','ok');
}catch(e){
toast('Camera toggle failed: '+(e.message||e),'err');
}
if(b) btnDone(b);
refreshRecognition(); // refresh both the panel button + the Recognition tab
}
async function setVisionEnabled(on){
try{
const r=await api('POST','/api/recognition/vision?on='+(on?'1':'0'));
toast(on?'Vision ON':'Vision OFF','ok');
refreshRecognition();
}catch(e){
toast('Vision toggle failed: '+(e.message||e),'err');
refreshRecognition();
}
}
async function setFaceRecEnabled(on){
try{
const r=await api('POST','/api/recognition/face-rec?on='+(on?'1':'0'));
toast(on?'Face Recognition ON':'Face Recognition OFF','ok');
if(r&&r.warning) toast(r.warning,'info');
refreshRecognition();
}catch(e){
toast('Face Rec toggle failed: '+(e.message||e),'err');
refreshRecognition();
}
}
async function syncGallery(b){
if(b) btnLoad(b);
try{await api('POST','/api/recognition/sync');toast('Gallery sync requested','ok');refreshRecognition();}
catch(e){toast('Sync failed','err');}
if(b) btnDone(b);
}
// Preview poller — bumps the img src each tick to defeat caching.
let _recPreviewTimer=null;
function startRecPreview(){
if(_recPreviewTimer) return;
const tick=()=>{
const img=document.getElementById('rec-preview-img');
if(img && img.style.display!=='none'){
img.src='/api/recognition/frame.jpg?t='+Date.now();
}
};
tick();
_recPreviewTimer=setInterval(tick,500);
}
function stopRecPreview(){if(_recPreviewTimer){clearInterval(_recPreviewTimer);_recPreviewTimer=null;}}
// Hook into tab switch — start/stop preview when recognition tab is active.
(function(){
const origSwitchTab=window.switchTab;
window.switchTab=function(name){
origSwitchTab(name);
if(name==='recognition'){refreshRecognition();refreshFaces();refreshZones();startRecPreview();}
else{stopRecPreview();}
};
})();
// Face CRUD stubs — filled in milestone 5
async function refreshFaces(){
const el=document.getElementById('rec-faces-list');
if(!el) return;
try{
const r=await api('GET','/api/recognition/faces');
if(!r.faces||!r.faces.length){el.innerHTML='<div class="empty">No faces enrolled yet</div>';return;}
el.innerHTML=r.faces.map(f=>renderFaceCard(f)).join('');
}catch(e){
el.innerHTML='<div class="empty">(face gallery not yet wired)</div>';
}
}
function renderFaceCard(f){
const name=f.name||`(face_${f.id})`;
const photos=(f.photos||[]).map(p=>{
const url=`/api/recognition/faces/${f.id}/photo/${encodeURIComponent(p.name)}`;
return `<div style="display:inline-block;margin:.2rem;text-align:center">
<img src="${url}?t=${Date.now()}" alt="${esc(p.name)}" style="width:72px;height:72px;object-fit:cover;border-radius:.3rem;background:#222"/>
<div style="font-size:.6rem;color:var(--dim);margin-top:.1rem">
<a href="${url}?download=1" download style="color:var(--accent);text-decoration:none">⬇</a>
<a href="#" onclick="deletePhoto(${f.id},'${esc(p.name)}');return false" style="color:var(--err);text-decoration:none;margin-left:.3rem">🗑</a>
</div>
</div>`;
}).join('');
return `<div class="card" style="margin-top:.5rem">
<div class="row" style="align-items:center">
<strong>face_${f.id}</strong>
<span style="color:var(--dim)">—</span>
<span id="rec-name-${f.id}" style="flex:1">${esc(name)}</span>
<button class="btn btn-ghost btn-sm" onclick="renameFace(${f.id})" title="Rename">✏</button>
<span style="color:var(--dim);font-size:.7rem">${(f.photos||[]).length} photo(s)</span>
</div>
<div style="margin-top:.25rem;font-size:.72rem">
<span style="color:var(--dim)">Description:</span>
<span id="rec-desc-${f.id}" style="color:var(--muted)">${f.description?esc(f.description):''}</span>${f.description?'':'<span style="color:var(--dim)">(none — no extra context for Gemini)</span>'}
<button class="btn btn-ghost btn-sm" onclick="describeFace(${f.id})" title="Edit description Gemini sees">✏</button>
</div>
<div style="margin-top:.3rem">${photos}</div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-success btn-sm" onclick="captureToFace(${f.id},this)">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">
📁 Upload
<input type="file" multiple accept="image/jpeg,image/png" style="display:none" onchange="uploadToFace(${f.id},this)">
</label>
<a class="btn btn-ghost btn-sm" href="/api/recognition/faces/${f.id}/download.zip" download>⬇ ZIP</a>
<button class="btn btn-danger btn-sm" style="margin-left:auto" onclick="deleteFace(${f.id})">🗑 Delete face</button>
</div>
</div>`;
}
// Build the ?name=&description= query string from the Add-New-Face inputs.
function _newFaceQuery(){
const name=document.getElementById('rec-newface-name').value.trim();
const desc=document.getElementById('rec-newface-desc').value.trim();
const qs=[];
if(name) qs.push('name='+encodeURIComponent(name));
if(desc) qs.push('description='+encodeURIComponent(desc));
return qs.length?('?'+qs.join('&')):'';
}
function _clearNewFaceInputs(){
document.getElementById('rec-newface-name').value='';
document.getElementById('rec-newface-desc').value='';
}
async function enrollFromCamera(b){
btnLoad(b);
try{
const r=await api('POST','/api/recognition/faces/enroll'+_newFaceQuery());
toast('Enrolled face_'+r.face.id+(r.face.description?' (with description)':''),'ok');
_clearNewFaceInputs();
refreshFaces();refreshRecognition();
}catch(e){toast('Enroll failed: '+(e.message||e),'err');}
btnDone(b);
}
async function enrollFromUpload(input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{
const resp=await fetch('/api/recognition/faces/upload'+_newFaceQuery(),{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());
const r=await resp.json();
toast('Uploaded face_'+r.face.id+' ('+files.length+' photos'+(r.face.description?', with description':'')+')','ok');
_clearNewFaceInputs();
input.value='';
refreshFaces();refreshRecognition();
}catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function captureToFace(id,b){
btnLoad(b);
try{await api('POST','/api/recognition/faces/'+id+'/capture');toast('Added photo','ok');refreshFaces();}
catch(e){toast('Capture failed','err');}
btnDone(b);
}
async function uploadToFace(id,input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{
const resp=await fetch('/api/recognition/faces/'+id+'/upload',{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());
toast('Uploaded '+files.length+' photo(s)','ok');
input.value='';
refreshFaces();
}catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function renameFace(id){
const el=document.getElementById('rec-name-'+id);if(!el)return;
const cur=el.textContent.replace(/^\((.*)\)$/,'$1');
const next=prompt('New name (blank to clear):',cur==='face_'+id?'':cur);
if(next===null) return;
try{
await api('POST','/api/recognition/faces/'+id+'/rename',{name:next});
toast('Renamed','ok');refreshFaces();
}catch(e){toast('Rename failed','err');}
}
async function describeFace(id){
const el=document.getElementById('rec-desc-'+id);
const cur=el?el.textContent.trim():'';
const next=prompt('Description for Gemini — who is this person? '+
'(blank to clear)',cur);
if(next===null) return;
try{
await api('POST','/api/recognition/faces/'+id+'/describe',{description:next});
toast(next.trim()?'Description saved':'Description cleared','ok');
refreshFaces();
}catch(e){toast('Save failed: '+(e.message||e),'err');}
}
async function deletePhoto(id,name){
if(!confirm('Delete photo '+name+'?'))return;
try{
await api('DELETE','/api/recognition/faces/'+id+'/photo/'+encodeURIComponent(name));
toast('Photo deleted','ok');refreshFaces();
}catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
async function deleteFace(id){
if(!confirm('Delete face_'+id+' and all photos?'))return;
try{
await api('DELETE','/api/recognition/faces/'+id);
toast('Face deleted','ok');refreshFaces();refreshRecognition();
}catch(e){toast('Delete failed','err');}
}
// ── Zones → Places → linked Faces (+ "go here" destination) ──
let _facesCache=[]; // [{id,name}] from the face gallery, for the link picker
let _navTarget=null; // active destination {zone_id,place_id,zone_name,place_name}
let _navMapsCache=[]; // [mapName,…] nav2 saved maps, for the zone↔map link picker
let _navPlacesCache={}; // {mapName: [placeName,…]} that map's nav2 places (with photos linkable)
async function setZoneRecEnabled(on){
try{await api('POST','/api/zones/zone-rec?on='+(on?'1':'0'));toast(on?'Zone Recognition ON':'Zone Recognition OFF','ok');refreshZones();}
catch(e){toast('Zone Rec toggle failed: '+(e.message||e),'err');refreshZones();}
}
async function syncZones(b){
if(b) btnLoad(b);
try{await api('POST','/api/zones/sync');toast('Zones sync requested','ok');refreshZones();}
catch(e){toast('Sync failed','err');}
if(b) btnDone(b);
}
async function clearNavTarget(b){
if(b) btnLoad(b);
try{await api('POST','/api/zones/nav/clear');toast('Destination cleared','ok');refreshZones();}
catch(e){toast('Clear failed','err');}
if(b) btnDone(b);
}
async function refreshZones(){
try{const fr=await api('GET','/api/recognition/faces');_facesCache=(fr.faces||[]).map(f=>({id:f.id,name:f.name||('face_'+f.id)}));}catch(e){_facesCache=[];}
try{
const r=await api('GET','/api/zones/state');
const t=document.getElementById('rec-zonerec-toggle'); if(t) t.checked=!!r.zone_rec_enabled;
const zs=document.getElementById('rec-zonerec-status'); if(zs){zs.textContent=r.zone_rec_enabled?'on':'off';zs.className='badge '+(r.zone_rec_enabled?'badge-ok':'');}
const zc=document.getElementById('rec-zones-count'); if(zc) zc.textContent=`(${r.zones_count} zones, ${r.places_count} places)`;
const zv=document.getElementById('rec-zones-version'); if(zv) zv.textContent='v.'+r.zones_version;
_navTarget=r.nav_target||null;
const nt=document.getElementById('rec-nav-target'), nc=document.getElementById('rec-nav-clear');
if(nt){
if(_navTarget){nt.textContent=(_navTarget.place_name||('place_'+_navTarget.place_id))+' · '+(_navTarget.zone_name||('zone_'+_navTarget.zone_id));nt.style.color='var(--accent)';}
else{nt.textContent='none';nt.style.color='var(--dim)';}
}
if(nc) nc.style.display=_navTarget?'':'none';
}catch(e){}
// nav2 saved maps — for binding a zone to a map (degrades to empty if nav down)
try{const mr=await api('GET','/api/nav/maps');_navMapsCache=(mr||[]).map(m=>m.name).filter(Boolean);}catch(e){_navMapsCache=[];}
const el=document.getElementById('rec-zones-list'); if(!el) return;
try{
const r=await api('GET','/api/zones');
if(!r.zones||!r.zones.length){el.innerHTML='<div class="empty">No zones yet — add one above</div>';return;}
// Prefetch the nav places for every linked map so the per-zone dropdowns
// (place picker + drives-to selector) render synchronously.
_navPlacesCache={};
const linkedMaps=[...new Set((r.zones||[]).map(z=>z.linked_map).filter(Boolean))];
for(const mp of linkedMaps){
try{const pr=await api('GET','/api/nav/places?map='+encodeURIComponent(mp));
_navPlacesCache[mp]=(pr||[]).map(p=>p.name).filter(Boolean);}catch(e){_navPlacesCache[mp]=[];}
}
el.innerHTML=r.zones.map(z=>renderZoneCard(z)).join('');
}catch(e){el.innerHTML='<div class="empty">(zone gallery not available)</div>';}
}
function _navMapOptions(sel){
return (_navMapsCache||[]).map(m=>`<option value="${esc(m)}" ${m===sel?'selected':''}>${esc(m)}</option>`).join('');
}
function _faceOptions(selectedIds){
const sel=new Set((selectedIds||[]).map(Number));
if(!_facesCache.length) return '<option disabled>(no saved faces)</option>';
return _facesCache.map(f=>`<option value="${f.id}" ${sel.has(f.id)?'selected':''}>${esc(f.name)}</option>`).join('');
}
function renderZoneCard(z){
const zname=z.name||`(zone_${z.id})`;
const lmap=z.linked_map||'';
const places=(z.places||[]).map(p=>renderPlaceCard(z.id,p,lmap)).join('') || '<div class="empty" style="margin:.3rem 0">No places in this zone yet</div>';
// Place-add row: when a map is linked, pick from that map's nav2 places
// (so the vision place IS a nav place + photos); otherwise free-text name.
const navPlaceOpts=(_navPlacesCache[lmap]||[]).map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');
const addRow = lmap ? `
<div class="row" style="margin-top:.4rem;padding-left:.6rem;gap:.3rem;flex-wrap:wrap">
<select id="z${z.id}-np-nav" style="flex:1;min-width:9rem;font-size:.78rem"><option value="">— pick a nav2 place —</option>${navPlaceOpts}</select>
<input id="z${z.id}-np-desc" placeholder="Description (optional)" style="flex:2;min-width:8rem;font-size:.78rem">
<button class="btn btn-success btn-sm" onclick="createPlaceInZone(${z.id},this)"> place</button>
</div>` : `
<div class="row" style="margin-top:.4rem;padding-left:.6rem;gap:.3rem;flex-wrap:wrap">
<input id="z${z.id}-np-name" placeholder="New place name" style="flex:1;min-width:8rem;font-size:.78rem">
<input id="z${z.id}-np-desc" placeholder="Description (optional)" style="flex:2;min-width:8rem;font-size:.78rem">
<button class="btn btn-success btn-sm" onclick="createPlaceInZone(${z.id},this)"> place</button>
</div>`;
return `<div class="card" style="margin-top:.6rem;border-left:3px solid var(--accent2)">
<div class="row" style="align-items:center;gap:.3rem">
<strong>📍 ${esc(zname)}</strong>
<button class="btn btn-ghost btn-sm" onclick="renameZone(${z.id})" title="Rename zone">✏</button>
<span style="flex:1;color:var(--muted);font-size:.72rem">${z.description?esc(z.description):'<span style=color:var(--dim)>(no description)</span>'}</span>
<button class="btn btn-ghost btn-sm" onclick="describeZone(${z.id})" title="Edit zone description">📝</button>
<span style="color:var(--dim);font-size:.7rem">${(z.places||[]).length} place(s)</span>
<button class="btn btn-danger btn-sm" onclick="deleteZone(${z.id})" title="Delete zone + its places">🗑</button>
</div>
<div class="row" style="margin-top:.35rem;gap:.3rem;align-items:center;flex-wrap:wrap">
<span style="color:var(--dim);font-size:.72rem">🗺 nav2 map:</span>
<select id="z${z.id}-map" onchange="linkZoneMap(${z.id},this.value)" style="font-size:.72rem;min-width:9rem">
<option value="">(none — link a map to drive)</option>${_navMapOptions(lmap)}
</select>
${lmap?`<button class="btn btn-primary btn-sm" onclick="geminiNavStart(${z.id},this)" title="Localize this zone's map + start a voice-driven nav session">🤖 Gemini Nav</button>`:''}
</div>
<div style="margin-top:.4rem;padding-left:.6rem">${places}</div>
${addRow}
</div>`;
}
function renderPlaceCard(zid,p,linkedMap){
const pname=p.name||`(place_${p.id})`;
// "Drives to" — links this vision place to a nav2 place in the zone's map.
let navRow='';
if(linkedMap){
const opts=(_navPlacesCache[linkedMap]||[]).map(n=>`<option value="${esc(n)}" ${p.nav_place===n?'selected':''}>${esc(n)}</option>`).join('');
navRow=`<div class="row" style="margin-top:.2rem;gap:.3rem;align-items:center;font-size:.72rem">
<span style="color:var(--dim)">🎯 Drives to:</span>
<select id="pn-${zid}-${p.id}" onchange="linkPlaceNav(${zid},${p.id},this.value)" style="font-size:.72rem;min-width:8rem">
<option value="">(not linked — announce only)</option>${opts}
</select>${p.nav_place?'<span class="badge badge-ok">drivable</span>':''}
</div>`;
}else if(p.nav_place){
navRow=`<div style="margin-top:.2rem;font-size:.7rem;color:var(--dim)">🎯 ${esc(p.nav_place)} (link a map to drive)</div>`;
}
const photos=(p.photos||[]).map(ph=>{
const url=`/api/zones/${zid}/places/${p.id}/photo/${encodeURIComponent(ph.name)}`;
return `<div style="display:inline-block;margin:.15rem;text-align:center">
<img src="${url}?t=${Date.now()}" alt="${esc(ph.name)}" style="width:64px;height:64px;object-fit:cover;border-radius:.3rem;background:#222"/>
<div style="font-size:.55rem"><a href="#" onclick="deletePlacePhoto(${zid},${p.id},'${esc(ph.name)}');return false" style="color:var(--err);text-decoration:none">🗑</a></div>
</div>`;
}).join('');
const chips=(p.faces||[]).map(f=>`<span class="badge" style="margin-right:.2rem">${esc(f.name||('face_'+f.id))}</span>`).join('') || '<span style="color:var(--dim);font-size:.7rem">none</span>';
const isDest=_navTarget&&_navTarget.zone_id===zid&&_navTarget.place_id===p.id;
return `<div class="card" style="margin-top:.35rem;background:var(--panel2)">
<div class="row" style="align-items:center;gap:.3rem">
<span>🏷</span><span id="rec-pname-${zid}-${p.id}" style="flex:1">${esc(pname)}</span>
<button class="btn btn-ghost btn-sm" onclick="renamePlace(${zid},${p.id})" title="Rename">✏</button>
${isDest?'<span class="badge badge-ok">destination</span>':`<button class="btn btn-primary btn-sm" onclick="goToPlace(${zid},${p.id},this)" title="Set as destination">▶ Go here</button>`}
<button class="btn btn-danger btn-sm" onclick="deletePlace(${zid},${p.id})">🗑</button>
</div>
<div style="margin-top:.2rem;font-size:.72rem"><span style="color:var(--dim)">Description:</span>
<span id="rec-pdesc-${zid}-${p.id}" style="color:var(--muted)">${p.description?esc(p.description):'<span style=color:var(--dim)>(none)</span>'}</span>
<button class="btn btn-ghost btn-sm" onclick="describePlace(${zid},${p.id})" title="Edit description">✏</button>
</div>
${navRow}
<div style="margin-top:.25rem;font-size:.72rem"><span style="color:var(--dim)">People here:</span> ${chips}</div>
<div class="row" style="margin-top:.2rem;gap:.3rem;align-items:center">
<select id="pf-${zid}-${p.id}" multiple size="3" style="font-size:.72rem;min-width:9rem">${_faceOptions(p.face_ids)}</select>
<button class="btn btn-ghost btn-sm" onclick="savePlaceFaces(${zid},${p.id})" title="Link selected saved faces to this place">Save people</button>
</div>
<div style="margin-top:.25rem">${photos}</div>
<div class="row" style="margin-top:.3rem;gap:.3rem">
<button class="btn btn-success btn-sm" onclick="captureToPlace(${zid},${p.id},this)">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">📁 Upload<input type="file" multiple accept="image/jpeg,image/png" style="display:none" onchange="uploadToPlace(${zid},${p.id},this)"></label>
<a class="btn btn-ghost btn-sm" href="/api/zones/${zid}/places/${p.id}/download.zip" download>⬇ ZIP</a>
</div>
</div>`;
}
async function createZone(b){
const name=document.getElementById('rec-newzone-name').value.trim();
const desc=document.getElementById('rec-newzone-desc').value.trim();
if(!name&&!desc){toast('Enter a zone name or description','err');return;}
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc));
btnLoad(b);
try{await api('POST','/api/zones/create?'+qs.join('&'));toast('Zone added','ok');
document.getElementById('rec-newzone-name').value='';document.getElementById('rec-newzone-desc').value='';refreshZones();}
catch(e){toast('Add zone failed: '+(e.message||e),'err');}
btnDone(b);
}
async function renameZone(zid){
const next=prompt('New zone name (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
}
async function describeZone(zid){
const next=prompt('Zone description (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
}
async function deleteZone(zid){
if(!confirm('Delete this zone AND all its places?'))return;
try{await api('DELETE','/api/zones/'+zid);toast('Zone deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
}
async function createPlaceInZone(zid,b){
const navEl=document.getElementById('z'+zid+'-np-nav'); // present only for linked-map zones
const nameEl=document.getElementById('z'+zid+'-np-name'), descEl=document.getElementById('z'+zid+'-np-desc');
const desc=(descEl?descEl.value:'').trim();
let name='', navp='';
if(navEl){ navp=(navEl.value||'').trim(); name=navp; if(!navp){toast('Pick a nav2 place','err');return;} }
else { name=(nameEl?nameEl.value:'').trim(); if(!name&&!desc){toast('Enter a place name or description','err');return;} }
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc)); if(navp)qs.push('nav_place='+encodeURIComponent(navp));
btnLoad(b);
try{await api('POST','/api/zones/'+zid+'/places/create?'+qs.join('&'));toast('Place added'+(navp?' (drives to '+navp+')':''),'ok');refreshZones();}
catch(e){toast('Add place failed: '+(e.message||e),'err');}
btnDone(b);
}
async function linkZoneMap(zid,map){
try{await api('POST','/api/zones/'+zid+'/link_map',{map:map||null});toast(map?('Linked map: '+map):'Map unlinked','ok');refreshZones();}
catch(e){toast('Link map failed: '+(e.message||e),'err');refreshZones();}
}
async function linkPlaceNav(zid,pid,navp){
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/nav_link',{nav_place:navp||null});toast(navp?('Drives to: '+navp):'Drive link removed','ok');refreshZones();}
catch(e){toast('Link failed: '+(e.message||e),'err');refreshZones();}
}
async function geminiNavStart(zid,b){
if(!confirm('Start Gemini Nav for this zone?\n\nThis loads the zones map (localize-only), turns ON camera + face + zone recognition + movement, and starts a voice session so you can tell the robot where to go.\n\nThe robot will DRIVE on command.'))return;
if(b)btnLoad(b);
try{
const r=await api('POST','/api/zones/'+zid+'/gemini_nav/start');
const okMap=r&&r.loaded&&r.loaded.ok;
if(okMap){ toast('Gemini Nav ready — say where to go','ok'); }
else { toast('Gemini Nav started, but map load had an issue: '+((r.loaded&&r.loaded.reason)||'unknown'),'err'); }
refreshZones();
}catch(e){toast('Gemini Nav failed: '+(e.message||e),'err');}
if(b)btnDone(b);
}
async function renamePlace(zid,pid){
const next=prompt('New place name (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
}
async function describePlace(zid,pid){
const next=prompt('Place description for Gemini (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
}
async function deletePlace(zid,pid){
if(!confirm('Delete this place and its photos?'))return;
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid);toast('Place deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
}
async function savePlaceFaces(zid,pid){
const sel=document.getElementById('pf-'+zid+'-'+pid); if(!sel)return;
const ids=Array.from(sel.selectedOptions).map(o=>parseInt(o.value)).filter(n=>!isNaN(n));
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/faces',{face_ids:ids});toast('People linked: '+ids.length,'ok');refreshZones();}
catch(e){toast('Save people failed: '+(e.message||e),'err');}
}
async function captureToPlace(zid,pid,b){
btnLoad(b);
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/capture');toast('Added photo','ok');refreshZones();}
catch(e){toast('Capture failed: '+(e.message||e),'err');}
btnDone(b);
}
async function uploadToPlace(zid,pid,input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{const resp=await fetch('/api/zones/'+zid+'/places/'+pid+'/upload',{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());toast('Uploaded '+files.length+' photo(s)','ok');input.value='';refreshZones();}
catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function deletePlacePhoto(zid,pid,name){
if(!confirm('Delete photo '+name+'?'))return;
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid+'/photo/'+encodeURIComponent(name));toast('Photo deleted','ok');refreshZones();}
catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
async function goToPlace(zid,pid,b){
if(b) btnLoad(b);
try{const r=await api('POST','/api/zones/'+zid+'/places/'+pid+'/go');toast('Destination set: '+((r.nav_target&&r.nav_target.place_name)||('place_'+pid)),'ok');refreshZones();}
catch(e){toast('Set destination failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
// ==================== Controller tab (N2) ====================
let ctrlArmed=false, ctrlTeleop=false, ctrlVel={vx:0,vy:0,vyaw:0}, ctrlKeys=new Set(), ctrlTimer=null;
const CTRL_LIN=0.05, CTRL_ANG=0.2;
// silent POST (no toast) for high-frequency teleop / stop
function ctrlPost(path,body){return fetch(API+path,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json().catch(()=>({}))).catch(()=>({}));}
function setPill(id,on){const el=document.getElementById(id);if(!el)return;el.classList.toggle('pill-on',!!on);el.classList.toggle('pill-off',!on);}
function ctrlSetArmed(on){
ctrlPost('/api/controller/arm?on='+(on?'1':'0')).then(r=>{
ctrlArmed=!!r.armed; if(!ctrlArmed) ctrlStopTeleop();
ctrlRenderArmed(); refreshStatusStrip();
toast('Movement '+(ctrlArmed?'ENABLED':'disabled'), ctrlArmed?'ok':'info');
});
}
function ctrlRenderArmed(){
const t=document.getElementById('ctrl-arm-toggle'); if(t) t.checked=ctrlArmed;
document.querySelectorAll('#tab-controller .btn').forEach(b=>{
const oc=b.getAttribute('onclick')||'';
if(/ctrlEstop|ctrlStop\b/.test(oc)) return; // E-STOP + Stop always live
b.disabled=!ctrlArmed;
});
setPill('ctrl-pill-movement',ctrlArmed);
}
async function ctrlEstop(b){btnLoad(b);try{await ctrlPost('/api/controller/estop');ctrlStopTeleop();toast('E-STOP sent','err');}catch(e){}btnDone(b);refreshController();}
function ctrlStop(b){btnLoad(b);ctrlPost('/api/controller/stop').then(()=>btnDone(b));}
async function ctrlStep(dir,b){btnLoad(b);try{const r=await api('POST','/api/controller/step?dir='+dir);if(r&&r.warning)toast(r.warning,'warn');}catch(e){}btnDone(b);}
async function ctrlMode(m,b){btnLoad(b);try{await api('POST','/api/controller/mode/'+m);toast(m.toUpperCase()+' done','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlPosture(p,b){btnLoad(b);try{const r=await api('POST','/api/controller/posture/'+p);if(r&&r.warning)toast(r.warning,'warn');else toast(p+' sent','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlBalance(m,b){btnLoad(b);try{await api('POST','/api/controller/balance?mode='+m);toast('balance '+(m?'gait':'static'),'info');}catch(e){}btnDone(b);}
async function ctrlMscSelectAi(b){btnLoad(b);try{await api('POST','/api/controller/msc/select-ai');toast('MSC → ai','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlMscRelease(b){btnLoad(b);try{await api('POST','/api/controller/msc/release');toast('MSC released','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlMscShow(b){btnLoad(b);try{const r=await api('GET','/api/controller/msc');toast('MSC mode: '+(r.mode_name||'?'),'info');}catch(e){}btnDone(b);}
async function ctrlReconnect(b){btnLoad(b);try{await api('POST','/api/controller/reconnect');toast('reconnected','ok');}catch(e){}btnDone(b);refreshController();}
function ctrlSetGeminiMove(on){
ctrlPost('/api/controller/gemini-movement?on='+(on?'1':'0')).then(r=>{
toast('Gemini movement '+(r.movement_enabled?'ENABLED':'disabled'), r.movement_enabled?'ok':'info');
refreshStatusStrip();
});
}
// continuous teleop @10 Hz — held keys ramp velocity; cap enforced server-side
function ctrlToggleTeleop(){ctrlTeleop?ctrlStopTeleop():ctrlStartTeleop();}
function ctrlStartTeleop(){
if(!ctrlArmed){toast('Enable movement first','warn');return;}
ctrlTeleop=true; const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Stop teleop';
window.addEventListener('keydown',ctrlKeyDown); window.addEventListener('keyup',ctrlKeyUp);
ctrlTimer=setInterval(ctrlTick,100);
}
function ctrlStopTeleop(){
if(!ctrlTeleop && !ctrlTimer) return;
ctrlTeleop=false; ctrlKeys.clear(); ctrlVel={vx:0,vy:0,vyaw:0};
if(ctrlTimer){clearInterval(ctrlTimer);ctrlTimer=null;}
window.removeEventListener('keydown',ctrlKeyDown); window.removeEventListener('keyup',ctrlKeyUp);
const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Start teleop (WASD / Q-E)';
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent='vx 0.00 · vy 0.00 · ω 0.00';
ctrlPost('/api/controller/stop');
}
function ctrlKeyDown(e){const k=(e.key||'').toLowerCase(); if(['w','a','s','d','q','e',' '].includes(k)){ctrlKeys.add(k);e.preventDefault();}}
function ctrlKeyUp(e){ctrlKeys.delete((e.key||'').toLowerCase());}
function ctrlTick(){
if(ctrlKeys.has(' ')){ctrlVel={vx:0,vy:0,vyaw:0};}
else{
ctrlVel.vx = ctrlKeys.has('w')? Math.min(2,ctrlVel.vx+CTRL_LIN) : ctrlKeys.has('s')? Math.max(-2,ctrlVel.vx-CTRL_LIN) : 0;
ctrlVel.vy = ctrlKeys.has('q')? Math.min(2,ctrlVel.vy+CTRL_LIN) : ctrlKeys.has('e')? Math.max(-2,ctrlVel.vy-CTRL_LIN) : 0;
ctrlVel.vyaw = ctrlKeys.has('a')? Math.min(3,ctrlVel.vyaw+CTRL_ANG) : ctrlKeys.has('d')? Math.max(-3,ctrlVel.vyaw-CTRL_ANG) : 0;
}
const run=(document.getElementById('ctrl-run-toggle')||{}).checked||false;
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent=`vx ${ctrlVel.vx.toFixed(2)} · vy ${ctrlVel.vy.toFixed(2)} · ω ${ctrlVel.vyaw.toFixed(2)}`;
ctrlPost('/api/controller/move',{vx:ctrlVel.vx,vy:ctrlVel.vy,vyaw:ctrlVel.vyaw,run});
}
async function refreshController(){
try{
const s=await api('GET','/api/controller/status');
ctrlArmed=!!s.armed; ctrlRenderArmed();
const fb=document.getElementById('ctrl-fsm-badge'); if(fb)fb.textContent='FSM '+(s.fsm_id??'—');
const dot=document.getElementById('ctrl-ready-dot'), rt=document.getElementById('ctrl-ready-text');
if(dot)dot.className='dot '+(s.walk_ready?'dot-ok':'dot-warn');
if(rt)rt.textContent=s.walk_ready?'walk-ready':('mode '+(s.fsm_mode??'?'));
const mb=document.getElementById('ctrl-msc-badge'); if(mb)mb.textContent='MSC '+(s.msc_mode||'—');
const sb=document.getElementById('ctrl-sdk-badge'); if(sb)sb.textContent=s.sdk_available?(s.lc_ready?'SDK live':'SDK init…'):'SIM';
}catch(e){}
try{const j=await api('GET','/api/controller/joints');
const el=document.getElementById('ctrl-joints');
if(el)el.textContent=(j.joints||[]).map(x=>`${String(x.idx).padStart(2)} ${String(x.name).padEnd(16)} ${Number(x.q).toFixed(3)}`).join('\n');
}catch(e){}
}
// subsystem pills (global + controller mirror) + Motion-tab lockout — polled ~2.5s
async function refreshStatusStrip(){
try{
const s=await api('GET','/api/controller/status/summary');
const cam=!!(s.vision_enabled&&s.camera_running);
setPill('pill-camera',cam); setPill('pill-face',s.face_rec_enabled); setPill('pill-place',s.zone_rec_enabled); setPill('pill-movement',s.movement_armed);
setPill('ctrl-pill-camera',cam); setPill('ctrl-pill-face',s.face_rec_enabled); setPill('ctrl-pill-place',s.zone_rec_enabled); setPill('ctrl-pill-movement',s.movement_armed);
setPill('ctrl-pill-gmove', s.gemini_movement_enabled);
const gt=document.getElementById('ctrl-gmove-toggle'); if(gt && document.activeElement!==gt) gt.checked=!!s.gemini_movement_enabled;
// keep the manual arm checkbox + button-enable state in sync even if the
// robot was disarmed elsewhere (e.g. E-STOP) and the Controller tab is open.
const at=document.getElementById('ctrl-arm-toggle');
if(at && document.activeElement!==at && (!!s.movement_armed)!==ctrlArmed){ ctrlArmed=!!s.movement_armed; ctrlRenderArmed(); }
applyMovementLock(!!s.movement_armed);
}catch(e){}
}
function applyMovementLock(armed){
const banner=document.getElementById('motion-lock-banner'); if(banner)banner.style.display=armed?'flex':'none';
const grid=document.getElementById('motion-grid'); if(grid)grid.classList.toggle('motion-locked',armed);
}
// Terminal tab — xterm.js attached to a WebSocket → PTY bridge on the robot.
// Backend: dashboard/websockets/terminal.py.
// Connection model: "SSH" button opens the socket + spawns the shell; the
// Terminal tab itself doesn't auto-connect so leaving it open in the
// background doesn't keep a bash running unnecessarily.
let termInstance=null, termFit=null, termWS=null, termAutoSizeBound=false;
function termLog(line){
if(termInstance) termInstance.write('\r\n\x1b[2m[term] '+line+'\x1b[0m\r\n');
}
// Control messages MUST be prefixed with \x1f (Unit Separator). The
// backend uses the prefix to distinguish a control frame from raw
// keystrokes — without it, a user who pastes `{"type":"resize",...}`
// into the shell would silently resize the PTY instead of pasting.
const TERM_CTRL_PREFIX='\x1f';
function termFitSafe(){
if(!termFit||!termInstance) return;
try{ termFit.fit(); }catch(e){ return; }
if(termWS && termWS.readyState===1){
try{
termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'resize', cols:termInstance.cols, rows:termInstance.rows}));
}catch(e){}
}
}
function termSetStatus(text,color){
const el=document.getElementById('term-status');
if(el){ el.textContent=text; el.style.color=color||'var(--dim)'; }
}
function termInit(){
if(termInstance) return;
if(typeof Terminal==='undefined'){ termSetStatus('xterm.js failed to load (check network/CDN)','var(--danger,#e57373)'); return; }
termInstance=new Terminal({
cursorBlink:true,
fontFamily:'ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace',
fontSize:13,
theme:{ background:'#000000', foreground:'#e0e0e0', cursor:'#00d4ff' },
scrollback:5000,
convertEol:true,
});
if(typeof FitAddon!=='undefined' && FitAddon.FitAddon){
termFit=new FitAddon.FitAddon();
termInstance.loadAddon(termFit);
}
termInstance.open(document.getElementById('term-host'));
termFitSafe();
// Send keystrokes upstream to the PTY.
termInstance.onData(function(d){
if(termWS && termWS.readyState===1){
try{ termWS.send(d); }catch(e){}
}
});
// Re-fit on window resize once xterm is attached.
if(!termAutoSizeBound){
window.addEventListener('resize', termFitSafe);
termAutoSizeBound=true;
}
}
async function termConnect(b){
termInit();
if(termWS && (termWS.readyState===0 || termWS.readyState===1)){
toast('Terminal already connected','info'); return;
}
btnLoad(b);
const scheme=(location.protocol==='https:'?'wss:':'ws:');
const url=scheme+'//'+location.host+'/ws/terminal';
termSetStatus('connecting…','var(--warn,#f5a623)');
try{
termWS=new WebSocket(url);
}catch(e){
termSetStatus('ws construct failed','var(--danger,#e57373)');
btnDone(b); return;
}
termWS.onopen=function(){
termSetStatus('connected','var(--success,#4caf50)');
document.getElementById('term-stop-btn').disabled=false;
document.getElementById('term-ssh-btn').disabled=true;
btnDone(b);
// Send initial sizing so the PTY knows the right window.
try{
if(termInstance) termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'init', cols:termInstance.cols, rows:termInstance.rows}));
}catch(e){}
termFitSafe();
if(termInstance) termInstance.focus();
};
termWS.onmessage=function(ev){
if(termInstance) termInstance.write(typeof ev.data==='string'?ev.data:'');
};
termWS.onerror=function(){
termSetStatus('ws error','var(--danger,#e57373)');
};
termWS.onclose=function(ev){
termSetStatus('disconnected (code '+ev.code+')','var(--dim)');
document.getElementById('term-stop-btn').disabled=true;
document.getElementById('term-ssh-btn').disabled=false;
btnDone(b);
if(termInstance) termLog('session closed');
};
}
function termDisconnect(b){
btnLoad(b);
try{ if(termWS) termWS.close(1000,'user disconnect'); }catch(e){}
termWS=null;
btnDone(b);
}
function termClear(){
if(termInstance){ termInstance.clear(); termInstance.focus(); }
}
// Mask Face tab — LED face mask over BLE (Project/Mask via the mask_face subsystem)
// Long BLE ops (connect/disconnect/face start/recolor) use an AbortController
// timeout so a wedged backend can never leave a button spinner stuck forever.
function maskApi(m,p,b,ms){
const c=new AbortController();const tm=setTimeout(()=>c.abort(),ms||120000);
const o={method:m,headers:{'Content-Type':'application/json'},signal:c.signal};
if(b)o.body=JSON.stringify(b);
return fetch(API+p,o).then(async r=>{let j={};try{j=await r.json();}catch(e){}
if(!r.ok){toast(j.detail||j.error||('Error '+r.status),'err');throw new Error(j.detail||r.status);}return j;})
.catch(e=>{if(e&&e.name==='AbortError'){toast('Mask request timed out','err');}throw e;})
.finally(()=>clearTimeout(tm));
}
function maskHexToRgb(h){h=(h||'').replace('#','');if(h.length===3)h=h.split('').map(c=>c+c).join('');const n=parseInt(h||'0',16);return [(n>>16)&255,(n>>8)&255,n&255];}
function maskRgbToHex(a){if(!a)return '#000000';const h=x=>('0'+(x&255).toString(16)).slice(-2);return '#'+h(a[0])+h(a[1])+h(a[2]);}
function maskSetConn(connected,connecting,reconnecting){
const d=document.getElementById('mask-conn-dot'),t=document.getElementById('mask-conn-text');
const busy=connecting||reconnecting;
if(d)d.className='dot '+(connected?'dot-ok':(busy?'dot-warn':''));
if(t)t.textContent=connected?'connected':(reconnecting?'reconnecting…':(connecting?'connecting…':'disconnected'));
}
async function refreshMask(){
try{
const s=await api('GET','/api/mask/status');
maskSetConn(s.connected,s.connecting,s.reconnecting);
const fb=document.getElementById('mask-face-badge'); if(fb){fb.textContent='FACE '+(s.face_running?'on':'off');fb.className='badge '+(s.face_running?'badge-ok':'');}
const sb=document.getElementById('mask-speak-badge'); if(sb){sb.textContent='SPEAK '+(s.speaking?'on':'off');sb.className='badge '+(s.speaking?'badge-info':'');}
const sp=document.getElementById('mask-speak-toggle'); if(sp&&document.activeElement!==sp)sp.checked=!!s.speaking;
const lk=document.getElementById('mask-link-toggle'); if(lk&&document.activeElement!==lk)lk.checked=!!s.gemini_linked;
const hm=document.getElementById('mask-hidemouth-toggle'); if(hm&&document.activeElement!==hm)hm.checked=!!s.hide_mouth;
const cb=document.getElementById('mask-connect-btn'); if(cb)cb.textContent=s.connected?'Reconnect':(s.reconnecting?'Reconnecting…':'Connect');
if(s.brightness!=null){const br=document.getElementById('mask-bright'),bv=document.getElementById('mask-bright-val'); if(br&&document.activeElement!==br)br.value=s.brightness; if(bv)bv.textContent=s.brightness;}
const ec=document.getElementById('mask-eye-color'); if(ec&&s.eye_color&&document.activeElement!==ec)ec.value=maskRgbToHex(s.eye_color);
const mc=document.getElementById('mask-mouth-color'); if(mc&&s.mouth_color&&document.activeElement!==mc)mc.value=maskRgbToHex(s.mouth_color);
const xc=document.getElementById('mask-sclera-color'); if(xc&&s.sclera_color&&document.activeElement!==xc)xc.value=maskRgbToHex(s.sclera_color);
const note=document.getElementById('mask-note');
if(note&&s.available!==false&&!s.lib_available){note.innerHTML='⚠ Mask library not importable here (need <b>bleak</b> + <b>Pillow</b> — run in g1_env). '+(s.last_error?esc(s.last_error):'');}
}catch(e){}
}
async function maskConnect(b){btnLoad(b);try{await maskApi('POST','/api/mask/connect',null,100000);toast('Mask connected','ok');}catch(e){}finally{btnDone(b);refreshMask();}}
async function maskDisconnect(b){btnLoad(b);try{await maskApi('POST','/api/mask/disconnect',null,25000);toast('Mask disconnected','info');}catch(e){}finally{btnDone(b);refreshMask();}}
async function maskFaceStart(b,reload){btnLoad(b);try{await maskApi('POST','/api/mask/face/start?reload='+(reload?'1':'0'),null,250000);toast(reload?'Face frames re-uploaded':'Face started','ok');}catch(e){}finally{btnDone(b);refreshMask();}}
async function maskFaceStop(b){btnLoad(b);try{await maskApi('POST','/api/mask/face/stop',null,15000);toast('Face stopped','info');}catch(e){}finally{btnDone(b);refreshMask();}}
async function maskReturnFace(b){btnLoad(b);try{await maskApi('POST','/api/mask/face/return',null,250000);toast('Live face resumed','ok');}catch(e){}finally{btnDone(b);refreshMask();}}
function maskSpeaking(on){api('POST','/api/mask/speaking?on='+(on?'1':'0')).then(()=>refreshMask()).catch(()=>{});}
async function maskLink(on){toast(on?'Linking Gemini to the mask…':'Unlinking…','info');try{const r=await maskApi('POST','/api/mask/link?on='+(on?'true':'false'),null,20000);if(on){toast(r&&r.connected?'Linked — Gemini can now do emotions on the mask':'Linking… mask connecting in the background','ok');}else{toast('Unlinked — mask idle, Gemini wont touch it','ok');}}catch(e){toast('Link toggle failed','err');}finally{refreshMask();}}
async function maskMouthHidden(hidden){toast(hidden?'Hiding mouth…':'Showing mouth…','info');try{await maskApi('POST','/api/mask/face/mouth?hidden='+(hidden?'true':'false'),null,90000);toast(hidden?'Mouth hidden (eyes only)':'Mouth shown','ok');}catch(e){toast('Failed — is the face running?','err');}}
function maskMouth(v){const e=document.getElementById('mask-mouth-val');if(e)e.textContent=v;api('POST','/api/mask/mouth?level='+v).catch(()=>{});}
async function maskExpr(name,b){btnLoad(b);try{await api('POST','/api/mask/expression/'+name);}catch(e){}finally{btnDone(b);}}
async function maskSocial(acc,b){btnLoad(b);try{await maskApi('POST','/api/mask/social/'+acc,null,60000);toast('Showing '+acc+' QR — scan the mask','ok');}catch(e){toast('QR failed — is the face running?','err');}finally{btnDone(b);}}
async function maskQrUpload(inp){const f=inp.files&&inp.files[0];if(!f){return;}const fd=new FormData();fd.append('file',f);try{const r=await fetch('/api/mask/qr',{method:'POST',body:fd});if(!r.ok){throw new Error('http '+r.status);}toast('QR shown on mask','ok');}catch(e){toast('QR upload failed','err');}finally{inp.value='';}}
async function maskResumeFace(b){btnLoad(b);try{await api('POST','/api/mask/face/resume');toast('Face resumed','ok');}catch(e){}finally{btnDone(b);refreshMask();}}
async function qrSave(inp){const f=inp.files&&inp.files[0];if(!f){return;}const nm=(document.getElementById('qr-save-name').value||f.name.replace(/\.[^.]+$/,'')).trim();const fd=new FormData();fd.append('file',f);try{const r=await fetch('/api/mask/qr/save?name='+encodeURIComponent(nm),{method:'POST',body:fd});if(!r.ok){throw new Error('http '+r.status);}toast('QR saved','ok');const ne=document.getElementById('qr-save-name');if(ne)ne.value='';qrLoadLibrary();}catch(e){toast('Save failed','err');}finally{inp.value='';}}
async function qrLoadLibrary(){const el=document.getElementById('qr-library');if(!el){return;}try{const r=await api('GET','/api/mask/qr/library');const items=(r&&r.qr)||[];if(!items.length){el.innerHTML='<span style="font-size:.62rem;color:var(--dim)">No saved QR codes yet — add one above.</span>';return;}el.innerHTML='';items.forEach(function(n){const d=document.createElement('div');d.style.cssText='text-align:center;font-size:.58rem;color:var(--muted);width:70px';d.innerHTML='<img src="/api/mask/qr/thumb/'+encodeURIComponent(n)+'?t='+Date.now()+'" style="width:64px;height:64px;object-fit:contain;background:#000;border:1px solid #333;border-radius:5px"><div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+n+'">'+n+'</div><div style="display:flex;gap:3px;justify-content:center;margin-top:2px"><button class="btn btn-primary btn-sm" style="padding:1px 6px" onclick="qrShow(\''+n+'\',this)">Show</button><button class="btn btn-ghost btn-sm" style="padding:1px 5px" onclick="qrDelete(\''+n+'\')" title="Delete">✕</button></div>';el.appendChild(d);});}catch(e){}}
async function qrShow(n,b){btnLoad(b);try{await maskApi('POST','/api/mask/qr/show/'+encodeURIComponent(n),null,60000);toast('Showing '+n,'ok');}catch(e){toast('Show failed — is the face running?','err');}finally{btnDone(b);}}
async function qrDelete(n){if(!confirm('Delete QR "'+n+'"?')){return;}try{await api('DELETE','/api/mask/qr/'+encodeURIComponent(n));toast('Deleted '+n,'ok');qrLoadLibrary();}catch(e){}}
async function qrSaveLink(b){const url=(document.getElementById('qr-link-url').value||'').trim();const nm=(document.getElementById('qr-link-name').value||url).trim();if(!url){toast('Enter a link','err');return;}btnLoad(b);try{const r=await api('POST','/api/mask/qr/save_link?name='+encodeURIComponent(nm)+'&url='+encodeURIComponent(url));if(r&&r.ok){toast(r.scannable_on_mask?'QR saved — scannable ✓':('QR saved — '+(r.note||'too dense')),r.scannable_on_mask?'ok':'info');document.getElementById('qr-link-url').value='';document.getElementById('qr-link-name').value='';qrLoadLibrary();}else{toast('Failed','err');}}catch(e){toast('Failed','err');}finally{btnDone(b);}}
async function textSave(inp){const t=(inp&&inp.value||'').trim();if(!t){return;}try{const r=await api('POST','/api/mask/texts/save?text='+encodeURIComponent(t));if(r&&r.ok){toast('Text saved','ok');inp.value='';textLoadLibrary();}else{toast('Save failed','err');}}catch(e){toast('Save failed','err');}}
async function textLoadLibrary(){const el=document.getElementById('text-library');if(!el){return;}try{const r=await api('GET','/api/mask/texts/library');const items=(r&&r.texts)||[];if(!items.length){el.innerHTML='<span style="font-size:.62rem;color:var(--dim)">No saved text yet.</span>';return;}el.innerHTML='';items.forEach(function(it){const d=document.createElement('div');d.style.cssText='display:flex;align-items:center;gap:4px;background:#1a1a1a;border:1px solid #333;border-radius:6px;padding:2px 4px 2px 8px;font-size:.66rem;color:var(--muted)';const safe=(it.text||it.name).replace(/</g,'&lt;');d.innerHTML='<span style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+safe+'">'+safe+'</span><button class="btn btn-primary btn-sm" style="padding:1px 7px" onclick="textShow(\''+it.name+'\',this)">Show</button><button class="btn btn-ghost btn-sm" style="padding:1px 5px" onclick="textDelete(\''+it.name+'\')">✕</button>';el.appendChild(d);});}catch(e){}}
async function textShow(n,b){btnLoad(b);try{await maskApi('POST','/api/mask/texts/show/'+encodeURIComponent(n),null,30000);toast('Showing text','ok');}catch(e){toast('Show failed — is the face running?','err');}finally{btnDone(b);}}
async function textDelete(n){if(!confirm('Delete this saved text?')){return;}try{await api('DELETE','/api/mask/texts/'+encodeURIComponent(n));toast('Deleted','ok');textLoadLibrary();}catch(e){}}
function maskBrightness(v){const e=document.getElementById('mask-bright-val');if(e)e.textContent=v;api('POST','/api/mask/brightness?level='+v).then(()=>toast('Brightness '+v,'info')).catch(()=>{});}
async function maskFaceColor(b){btnLoad(b);try{const eye=maskHexToRgb(document.getElementById('mask-eye-color').value);const mouth=maskHexToRgb(document.getElementById('mask-mouth-color').value);const sclera=maskHexToRgb(document.getElementById('mask-sclera-color').value);const r=await maskApi('POST','/api/mask/face/color',{eye:eye,mouth:mouth,sclera:sclera},250000);toast(r.reuploaded?'Face recolored (re-uploaded)':'Colors saved (apply on Run face)','ok');}catch(e){}finally{btnDone(b);refreshMask();}}
async function maskText(b){btnLoad(b);try{const t=document.getElementById('mask-text').value;const c=maskHexToRgb(document.getElementById('mask-color').value);const m=parseInt(document.getElementById('mask-text-mode').value,10);const bgOn=document.getElementById('mask-text-bg-on');const bgEl=document.getElementById('mask-text-bg');const bg=(bgOn&&bgOn.checked&&bgEl)?maskHexToRgb(bgEl.value):null;const spEl=document.getElementById('mask-text-speed');const sp=spEl?parseInt(spEl.value,10):null;await api('POST','/api/mask/text',{text:t,color:c,mode:m,bg:bg,speed:sp});toast('Text sent','ok');}catch(e){}finally{btnDone(b);}}
async function maskImage(b){btnLoad(b);try{const id=document.getElementById('mask-img-id').value;await api('POST','/api/mask/image?id='+id);toast('Image '+id,'ok');}catch(e){}finally{btnDone(b);}}
async function maskAnim(b){btnLoad(b);try{const id=document.getElementById('mask-anim-id').value;await api('POST','/api/mask/animation?id='+id);toast('Animation '+id,'ok');}catch(e){}finally{btnDone(b);}}
function maskStep(inputId,delta,lo,hi,fn){const el=document.getElementById(inputId);if(!el)return;let v=(parseInt(el.value,10)||0)+delta;if(v<lo)v=hi;if(v>hi)v=lo;el.value=v;if(fn)fn(null);}
async function maskClear(b){btnLoad(b);try{const r=await maskApi('POST','/api/mask/clear',null,30000);toast('Cleared '+(r.removed||0)+' frames','info');}catch(e){}finally{btnDone(b);refreshMask();}}
// ==================== Navigation (web_nav3 / Nav2) ====================
// Backend proxy lives under /nav/* on the dashboard; the live map + full nav
// dashboard are served by web_nav3 on the robot at :8765. Host is derived from
// the dashboard's own hostname (same robot), so it works on the LAN unchanged.
const NAV_WEB_PORT=8765;
function navHost(){return location.hostname||'localhost';}
// Deployment override: the backend resolves rosbridge + web_nav3 URLs from
// env/config and exposes them at GET /api/nav/config. Prefer those so moving
// rosbridge off :9090 or web_nav3 off :8765 doesn't silently break the browser
// map/iframe. Falls back to the hardcoded host-derived defaults until fetched.
let _navCfg={web_nav3_url:null,rosbridge_url:null};
// The backend config often resolves to a loopback host (default
// http://127.0.0.1:8765 / ws://127.0.0.1:9090) because web_nav3 runs ON the
// robot. A REMOTE browser must not connect to 127.0.0.1 (that's the operator's
// own machine), so rewrite a loopback/wildcard host to the host the browser
// actually reached the dashboard on, while honoring the configured port/scheme.
function _navRewriteUrl(cfgUrl,fallbackProto,fallbackPort){
const proto0=(location.protocol==='https:'),defProto=fallbackProto+(proto0?'s:':':');
if(cfgUrl){
try{
const u=new URL(cfgUrl);
const loop=(u.hostname==='127.0.0.1'||u.hostname==='localhost'||u.hostname==='0.0.0.0'||u.hostname==='::1'||u.hostname==='');
const host=loop?navHost():u.hostname;
const port=u.port?(':'+u.port):(fallbackPort?(':'+fallbackPort):'');
// upgrade ws->wss / http->https when the page is served over TLS.
let scheme=u.protocol.replace(':','');
if(proto0){ if(scheme==='ws')scheme='wss'; else if(scheme==='http')scheme='https'; }
return scheme+'://'+host+port;
}catch(e){}
}
return defProto+'//'+navHost()+(fallbackPort?(':'+fallbackPort):'');
}
function navWebUrl(){return _navRewriteUrl(_navCfg.web_nav3_url,'http',NAV_WEB_PORT);}
function navRosbridgeUrl(){return _navRewriteUrl(_navCfg.rosbridge_url,'ws',9090);}
function navWebLabel(){try{const u=new URL(navWebUrl());return u.host;}catch(e){return navHost()+':'+NAV_WEB_PORT;}}
async function navLoadConfig(){
try{
const c=await api('GET','/api/nav/config');
if(c&&c.web_nav3_url)_navCfg.web_nav3_url=c.web_nav3_url;
if(c&&c.rosbridge_url)_navCfg.rosbridge_url=c.rosbridge_url;
}catch(e){}
navInitHost();
}
function navInitHost(){
const url=navWebUrl(),label=navWebLabel();
const link=document.getElementById('nav-link'); if(link){link.href=url;link.textContent=label;}
const hc=document.getElementById('nav-map-host'); if(hc)hc.textContent=label;
// The Navigation tab now renders the map natively (navMapConnect → #navMapCanvas).
// The full web_nav3 iframe lives in the dedicated "Live Map" tab.
}
function navSetBadge(id,ok,label){const el=document.getElementById(id);if(!el)return;el.textContent=label;el.className='badge '+(ok===true?'badge-ok':ok===false?'badge-err':'');}
async function refreshNavigation(){
navInitHost();
try{
const s=await api('GET','/api/nav/status');
const reach=(s.web_nav3_reachable??s.reachable);
const sb=document.getElementById('nav-state-badge'); if(sb)sb.textContent='NAV '+(s.nav_state||s.state||(reach?'online':'offline'));
const dot=document.getElementById('nav-ready-dot'), rt=document.getElementById('nav-ready-text');
if(dot)dot.className='dot '+(reach?'dot-ok':'dot-err');
if(rt)rt.textContent=reach?'web_nav3 reachable':'unreachable';
navSetBadge('nav-bringup-badge', s.bringup_alive, 'BRINGUP '+(s.bringup_alive===true?'up':s.bringup_alive===false?'down':'—'));
navSetBadge('nav-bridge-badge', s.rosbridge_alive, 'BRIDGE '+(s.rosbridge_alive===true?'up':s.rosbridge_alive===false?'down':'—'));
// MODE: truthful state of the ONE robot (mapping fresh/continue, localizing
// a saved map, or idle). Shared globally so the Map Editor tab can use it.
window._navModeStatus = s;
const ml = s.mode_label || (reach ? (s.bringup_alive ? 'running' : 'idle') : '—');
navSetBadge('nav-mode-badge', (s.mapping||s.localizing)?true:null, 'MODE: '+ml);
}catch(e){
navSetBadge('nav-bringup-badge', null, 'BRINGUP —');
navSetBadge('nav-bridge-badge', null, 'BRIDGE —');
navSetBadge('nav-mode-badge', null, 'MODE —');
window._navModeStatus = null;
const dot=document.getElementById('nav-ready-dot'), rt=document.getElementById('nav-ready-text');
if(dot)dot.className='dot dot-err'; if(rt)rt.textContent='status unavailable';
}
try{
const mp=_navMap&&_navMap.mapName;
const r=await api('GET','/api/nav/places'+(mp?('?map='+encodeURIComponent(mp)):''));
const places=r.places||r.items||(Array.isArray(r)?r:[]);
if(typeof _navMap!=="undefined"){ _navMap.places=places; navMapRender(); }
const wrap=document.getElementById('nav-places-list');
const cnt=document.getElementById('nav-places-count'); if(cnt)cnt.textContent=places.length?('· '+places.length):'';
if(wrap){
wrap.innerHTML = places.length ? '' : '<div class="empty">'+(mp?'No places in this map yet — switch to ADD and click the map.':'Load a map (above) to see / add its places.')+'</div>';
places.forEach(p=>{
const name=p.name||p.id||p;
const pose=p.pose||{};
const coord=(pose.x!=null&&pose.y!=null)?('x='+(+pose.x).toFixed(2)+' y='+(+pose.y).toFixed(2)):'';
const row=document.createElement('div');
row.className='action-row';
row.innerHTML='<span class="r-name">'+esc(name)+'</span>'+
(coord?'<span class="r-meta">'+esc(coord)+'</span>':'')+
'<span style="margin-left:auto;display:flex;gap:4px">'+
'<button class="action-btn" data-act="go" title="Drive here">Go</button>'+
'<button class="action-btn" data-act="move" title="Move on map">Move</button>'+
'<button class="action-btn" data-act="rn" title="Rename">✎</button>'+
'<button class="action-btn" data-act="del" title="Delete">✕</button>'+
'</span>';
row.querySelectorAll('button').forEach(btn=>{btn.onclick=function(ev){ev.stopPropagation();
const a=btn.getAttribute('data-act');
if(a==='go')navGoPlace(name,btn);
else if(a==='move')navMovePlace(name);
else if(a==='rn')navRenamePlace(name);
else if(a==='del')navDeletePlace(name);
};});
wrap.appendChild(row);
});
}
}catch(e){
const wrap=document.getElementById('nav-places-list'); if(wrap)wrap.innerHTML='<div class="empty">Places unavailable.</div>';
}
try{
const r=await api('GET','/api/nav/missions');
const missions=r.missions||r.items||(Array.isArray(r)?r:[]);
const wrap=document.getElementById('nav-missions-list');
const cnt=document.getElementById('nav-missions-count'); if(cnt)cnt.textContent=missions.length?('· '+missions.length):'';
if(wrap){
wrap.innerHTML = missions.length ? '' : '<div class="empty">No missions defined.</div>';
missions.forEach(m=>{
const id=m.id??m.mission_id??m.name;
const name=m.name||m.title||id;
const meta=m.description||((m.waypoints!=null)?(m.waypoints+' waypoints'):'');
const row=document.createElement('div');
row.className='action-row';
row.innerHTML='<span class="r-name">'+esc(name)+'</span>'+
(meta?'<span class="r-meta">'+esc(meta)+'</span>':'')+
'<button class="action-btn" style="margin-left:auto">Run</button>';
row.querySelector('button').onclick=function(ev){ev.stopPropagation();navRunMission(id,this);};
wrap.appendChild(row);
});
}
}catch(e){
const wrap=document.getElementById('nav-missions-list'); if(wrap)wrap.innerHTML='<div class="empty">Missions unavailable.</div>';
}
navRefreshMaps();
}
function navFindPlace(name){return (_navMap.places||[]).find(p=>(p.name||p.id)===name);}
// Send the robot to a map coordinate. First CLAIMS the legs for Nav2 via the
// arbiter (409 if the Controller tab is armed → refuse, so two stacks never
// both drive), then publishes /goal_pose over rosbridge.
async function navSendGoal(wx,wy,yaw){
yaw=yaw||0;
// Arbiter gate. On 409 the detail ("…Controller is armed…") is already shown
// by api()'s own error toast — don't double-toast; just bail.
try{ await api('POST','/api/nav/goto_pose',{name:'_goal_',x:wx,y:wy,yaw:yaw}); }
catch(e){ return false; }
const gp=_navMap.goalPub;
if(!gp){ toast('Map not connected — Load & View first','warn'); return false; }
gp.publish(new ROSLIB.Message({header:{frame_id:'map'},
pose:{position:{x:wx,y:wy,z:0},orientation:{x:0,y:0,z:Math.sin(yaw/2),w:Math.cos(yaw/2)}}}));
toast('Going to ('+wx.toFixed(2)+', '+wy.toFixed(2)+')','ok');
return true;
}
async function navGoPlace(name,b){
const p=navFindPlace(name); if(!p||!p.pose){toast('place pose unknown','warn');return;}
if(b){b.classList.add('running');}
await navSendGoal(+p.pose.x,+p.pose.y, p.pose.yaw||0);
if(b){b.classList.remove('running');}
}
async function navRenamePlace(name){
const nn=(prompt('Rename “'+name+'” to:',name)||'').trim(); if(!nn||nn===name)return;
const mp=_navMap.mapName;
try{ await api('POST','/api/nav/places/rename'+(mp?('?map='+encodeURIComponent(mp)):''),{old:name,new:nn});
toast('Renamed → '+nn,'ok'); refreshNavigation(); }
catch(e){ toast('rename failed: '+(e.message||e),'err'); }
}
async function navDeletePlace(name){
if(!confirm('Delete place “'+name+'”?'))return;
const mp=_navMap.mapName;
try{ await api('POST','/api/nav/places/delete'+(mp?('?map='+encodeURIComponent(mp)):''),{name:name});
toast('Deleted “'+name+'”','ok'); refreshNavigation(); }
catch(e){ toast('delete failed: '+(e.message||e),'err'); }
}
function navMovePlace(name){
_navMap.moveTarget=name;
const cv=document.getElementById("navMapCanvas"); if(cv)cv.style.cursor='crosshair';
toast('Click the map: new location for “'+name+'”','ok');
}
async function navRefreshMaps(){
try{const maps=await api("GET","/api/nav/maps");const arr=maps.maps||maps.items||(Array.isArray(maps)?maps:[]);
const sel=document.getElementById("navMapSelect"); if(!sel)return;
sel.innerHTML = arr.length? arr.map(m=>`<option value="${esc(m.path||m.name)}">${esc(m.name||m.path)} ${m.size_mb?("("+esc(m.size_mb)+"MB)"):""}</option>`).join("") : `<option disabled>no saved maps</option>`;
}catch(e){}
}
async function navLoadMap(b){
const sel=document.getElementById("navMapSelect"); const db=sel&&sel.value; if(!db){toast("No map selected","warn");return;}
// Track which map is loaded — places are scoped PER MAP (each saved map keeps
// its own places, since poses only make sense in that map's frame).
_navMap.mapName=((db.split('/').pop()||'').replace(/\.db$/,''))||null;
const ml=document.getElementById('navMapLabel'); if(ml)ml.textContent=_navMap.mapName||'—';
if(b){b.classList.add("running");b.textContent="Loading…";}
// localize-mode start so /map publishes the saved grid without rebuilding.
// If bringup is ALREADY running (409 Conflict), the map is already live —
// treat that as success and just (re)connect the canvas to rosbridge.
// load_map STOPS any running bringup (incl. a fresh-mapping session) and
// re-launches LOCALIZE-only against this saved map — so "Load & View" really
// shows the chosen map instead of attaching to whatever was already running.
try{
await api("POST","/api/nav/load_map",{db_path:db});
toast("Loading “"+(_navMap.mapName||"map")+"” (localize) — grid appears in a few seconds","ok");
}catch(e){ toast("load failed: "+(e.message||e),"err"); }
if(typeof _navMap!=="undefined"){ _navMap.started=false; }
navMapConnect();
if(b){b.classList.remove("running");b.textContent="Load & View";}
setTimeout(refreshNavigation,2500);
}
async function navCancel(b){
if(b){b.classList.add("running");}
let done=false;
try{
const ros=_navMap&&_navMap.ros;
if(ros&&ros.isConnected){
// 1. cancel the active Nav2 goal via the ROS2 action cancel SERVICE.
// roslib 1.4.1's ActionClient.cancel() speaks the ROS1 actionlib
// protocol (publishes on /navigate_to_pose/cancel) which Nav2 ignores;
// ROS2 actions cancel through /navigate_to_pose/_action/cancel_goal
// (action_msgs/srv/CancelGoal). All-zero goal_id + zero stamp = cancel
// every active goal. Mirrors web_nav3 map_viewer.js cancelGoal().
const cancelSrv=new ROSLIB.Service({ros,name:"/navigate_to_pose/_action/cancel_goal",serviceType:"action_msgs/srv/CancelGoal"});
const req=new ROSLIB.ServiceRequest({goal_info:{goal_id:{uuid:Array(16).fill(0)},stamp:{sec:0,nanosec:0}}});
cancelSrv.callService(req,
()=>toast("Goal cancelled — robot stopped","ok"),
err=>toast("Cancel error: "+err,"err"));
// 2. publish zero Twist to /cmd_vel as a momentary hard-stop backstop
// (a few times). The cancel above is what actually ends the goal so the
// controller stops re-issuing cmd_vel; this just halts coasting faster.
const tw=new ROSLIB.Topic({ros,name:"/cmd_vel",messageType:"geometry_msgs/msg/Twist"});
const zero=new ROSLIB.Message({linear:{x:0,y:0,z:0},angular:{x:0,y:0,z:0}});
let n=0; const t=setInterval(()=>{tw.publish(zero);if(++n>=5)clearInterval(t);},100);
// The cancel toast is fired by the service callback above (only on a real
// response), so do NOT assert "stopped" here unconditionally.
done=true;
}
}catch(e){}
if(!done){ try{await api("POST","/api/nav/cancel");toast("Cancel sent","ok");}catch(e){} }
if(b){b.classList.remove("running");}
refreshNavigation();
}
async function navSaveHere(b){
const inp=document.getElementById('nav-save-name');
const name=(inp&&inp.value||'').trim();
if(!name){toast('Enter a place name first','warn');return;}
// Save the robot's CURRENT pose (from /tf) into THIS map's places — fully
// coordinate-based + per-map, so it works the same as click-to-add.
const p=_navMap&&_navMap.pose;
if(!p){toast('Robot pose unknown — Load & View a map first','warn');return;}
const mp=_navMap.mapName;
btnLoad(b);
try{ await api('POST','/api/nav/save_at'+(mp?('?map='+encodeURIComponent(mp)):''),{name:name,x:p.x,y:p.y,yaw:p.t||0});
toast('Saved place “'+name+'”','ok'); if(inp)inp.value=''; }
catch(e){ toast('save failed: '+(e.message||e),'err'); }
btnDone(b);refreshNavigation();
}
async function navRunMission(id,b){
if(b){b.classList.add('running');b.textContent='Running…';}
try{await api('POST','/api/nav/missions/run',{id:id});toast('Mission started','ok');}catch(e){}
if(b){b.classList.remove('running');b.textContent='Run';}
refreshNavigation();
}
// ── Navigation-tab native map viewer (read-only) ──
// Connects directly to the robot's rosbridge (ws://<host>:9090), subscribes
// /map (OccupancyGrid, cbor) + /tf (robot pose), and renders to #navMapCanvas.
// No goal-click / no publishing — read-only. Self-contained; needs roslibjs.
let _navMap={ros:null,info:null,data:null,pose:null,zoom:1,cache:null,dirty:true,started:false,mode:'view',places:[],clickBound:false,mapName:null,goalPub:null,moveTarget:null};
function navMapConnect(){
navRefreshMaps();
navBindCanvasClick();
if(_navMap.started) return; _navMap.started=true;
// Tear down any previous rosbridge connection (and its /map + /map_relay +
// /tf subscriptions) before opening a new one, so repeated "Load & View"
// doesn't leak a websocket + 3 subscriptions per press. Detach the close
// handler first so this deliberate close can't schedule a retry.
if(_navMap.ros){ try{_navMap.ros.removeAllListeners&&_navMap.ros.removeAllListeners();}catch(e){} try{_navMap.ros.close();}catch(e){} _navMap.ros=null; }
_navMap.started=true; // re-assert in case the old close handler reset it
const url=navRosbridgeUrl();
const st=document.getElementById("navMapStatus");
const setS=(m,ok)=>{if(st){st.textContent=m;st.style.color=ok?"#4ade80":"#f87171";}};
if(typeof ROSLIB==="undefined"){setS("roslib not loaded",false);return;}
const ros=new ROSLIB.Ros({url}); _navMap.ros=ros;
ros.on("connection",()=>setS("● live "+url,true));
ros.on("close",()=>{setS("● closed — retry 3s",false);_navMap.started=false;setTimeout(navMapConnect,3000);});
ros.on("error",()=>setS("● rosbridge error",false));
// NOTE: no cbor compression on /map — rosbridge on Foxy throws
// "'OccupancyGrid' object has no attribute '_slot_types'" for CBOR-encoded
// OccupancyGrid, so the grid never reaches the browser. Plain JSON works.
// Subscribe BOTH /map (event-driven, updates on motion) and /map_relay
// (map_relay.py re-publishes the latched grid at 1 Hz so a stationary
// robot's map still arrives over rosbridge's VOLATILE subscription).
const onNavGrid=m=>{_navMap.info=m.info;_navMap.data=m.data;_navMap.dirty=true;navMapRender();};
new ROSLIB.Topic({ros,name:"/map",messageType:"nav_msgs/msg/OccupancyGrid"}).subscribe(onNavGrid);
new ROSLIB.Topic({ros,name:"/map_relay",messageType:"nav_msgs/msg/OccupancyGrid"}).subscribe(onNavGrid);
let mo={x:0,y:0,t:0},ob={x:0,y:0,t:0};
const yaw=q=>Math.atan2(2*(q.w*q.z+q.x*q.y),1-2*(q.y*q.y+q.z*q.z));
new ROSLIB.Topic({ros,name:"/tf",messageType:"tf2_msgs/msg/TFMessage"}).subscribe(m=>{
for(const t of m.transforms){const tr=t.transform;
if(t.header.frame_id==="map"&&t.child_frame_id==="odom")mo={x:tr.translation.x,y:tr.translation.y,t:yaw(tr.rotation)};
else if(t.header.frame_id==="odom"&&t.child_frame_id==="base_link")ob={x:tr.translation.x,y:tr.translation.y,t:yaw(tr.rotation)};}
const c=Math.cos(mo.t),s=Math.sin(mo.t);
_navMap.pose={x:mo.x+c*ob.x-s*ob.y,y:mo.y+s*ob.x+c*ob.y,t:mo.t+ob.t};navMapRender();});
// Goal publisher — click-to-drive + "Go to place" publish a PoseStamped on
// /goal_pose, which goal_pose_forwarder turns into a Nav2 NavigateToPose goal.
_navMap.goalPub=new ROSLIB.Topic({ros,name:"/goal_pose",messageType:"geometry_msgs/msg/PoseStamped"});
}
function navMapRender(){
const cv=document.getElementById("navMapCanvas"),M=_navMap; if(!cv||!M.info||!M.data)return;
const ctx=cv.getContext("2d"),W=M.info.width,H=M.info.height,z=M.zoom;
cv.width=W*z; cv.height=H*z;
if(M.dirty||!M.cache){M.cache=document.createElement("canvas");M.cache.width=W;M.cache.height=H;
const oc=M.cache.getContext("2d"),img=oc.createImageData(W,H);
for(let i=0;i<M.data.length;i++){const v=M.data[i];let r,g,b,a=255;
if(v===-1){r=46;g=52;b=64;a=160;}else if(v<25){r=210;g=216;b=226;}else if(v>75){r=15;g=17;b=21;}else{r=140;g=148;b=160;}
const x=i%W,y=H-1-Math.floor(i/W),idx=4*(y*W+x);img.data[idx]=r;img.data[idx+1]=g;img.data[idx+2]=b;img.data[idx+3]=a;}
oc.putImageData(img,0,0);M.dirty=false;}
ctx.imageSmoothingEnabled=false;ctx.drawImage(M.cache,0,0,W*z,H*z);
const ox=M.info.origin.position.x,oy=M.info.origin.position.y,res=M.info.resolution;
// saved places — green dots + labels
(M.places||[]).forEach(p=>{
const wx=(p.pose&&(p.pose.x??p.pose.position?.x)), wy=(p.pose&&(p.pose.y??p.pose.position?.y));
if(wx==null||wy==null) return;
const px=((wx-ox)/res)*z, py=(H-(wy-oy)/res)*z;
ctx.fillStyle="#4ade80";ctx.beginPath();ctx.arc(px,py,5,0,Math.PI*2);ctx.fill();
ctx.strokeStyle="#06080d";ctx.lineWidth=1.5;ctx.stroke();
ctx.fillStyle="#d8f5e3";ctx.font="11px sans-serif";ctx.fillText(p.name||"",px+7,py-5);
});
if(M.pose){
const px=((M.pose.x-ox)/res)*z,py=(H-(M.pose.y-oy)/res)*z;
ctx.strokeStyle="#88c0d0";ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(px+14*Math.cos(-M.pose.t),py+14*Math.sin(-M.pose.t));ctx.stroke();
ctx.fillStyle="#88c0d0";ctx.beginPath();ctx.arc(px,py,4,0,Math.PI*2);ctx.fill();}
}
function navMapZoom(d){
const M=_navMap;
if(d===0){
// "Fit": choose the zoom that makes the whole grid fit the scroll
// container, instead of resetting to native 1px/cell. Falls back to 1 if
// the map or container size isn't measurable yet.
M.zoom=navMapFitZoom();
}else{
M.zoom=Math.max(0.25,Math.min(8,M.zoom*(d>0?1.5:1/1.5)));
}
navMapRender();
}
function navMapFitZoom(){
const M=_navMap,cv=document.getElementById("navMapCanvas");
if(!M.info||!cv||!cv.parentElement) return 1;
const W=M.info.width,H=M.info.height;
if(!W||!H) return 1;
const box=cv.parentElement,cs=getComputedStyle(box);
const padX=(parseFloat(cs.paddingLeft)||0)+(parseFloat(cs.paddingRight)||0);
const padY=(parseFloat(cs.paddingTop)||0)+(parseFloat(cs.paddingBottom)||0);
const cw=Math.max(1,box.clientWidth-padX),ch=Math.max(1,box.clientHeight-padY);
return Math.max(0.25,Math.min(8,Math.min(cw/W,ch/H)));
}
// Map click modes: 'view' (no-op / pan), 'goal' (drive the robot there),
// 'add' (bookmark a place). A pending Move reposition overrides the mode once.
function navSetMode(mode){
_navMap.moveTarget=null;
_navMap.mode=mode;
['view','goal','add'].forEach(m=>{const b=document.getElementById('navMode_'+m); if(b)b.classList.toggle('running',m===mode);});
const cv=document.getElementById("navMapCanvas"); if(cv)cv.style.cursor=(mode==='view'?'default':'crosshair');
const hint=document.getElementById('navModeHint');
if(hint)hint.textContent = mode==='goal'?'GOAL — click the map to DRIVE the robot there.'
: mode==='add'?'ADD — click the map to save a place at that spot.'
: 'VIEW — pick GOAL to drive, or ADD to bookmark places.';
}
function _navClickWorld(e,cv){
const M=_navMap,rect=cv.getBoundingClientRect();
const cx=(e.clientX-rect.left)*(cv.width/rect.width);
const cy=(e.clientY-rect.top)*(cv.height/rect.height);
const ox=M.info.origin.position.x,oy=M.info.origin.position.y,res=M.info.resolution,z=M.zoom,H=M.info.height;
return [ox+(cx/z)*res, oy+(H-cy/z)*res];
}
function navBindCanvasClick(){
if(_navMap.clickBound) return;
const cv=document.getElementById("navMapCanvas"); if(!cv) return;
_navMap.clickBound=true;
cv.addEventListener("click", async e=>{
const M=_navMap;
if(M.mode==='view' && !M.moveTarget) return;
if(!M.info||!M.data){ toast("Load a map first (Load & View)","warn"); return; }
const [wx,wy]=_navClickWorld(e,cv);
const mp=M.mapName;
// pending Move reposition takes precedence
if(M.moveTarget){
const nm=M.moveTarget; M.moveTarget=null; navSetMode(M.mode);
try{ await api('POST','/api/nav/save_at'+(mp?('?map='+encodeURIComponent(mp)):''),{name:nm,x:wx,y:wy,yaw:0});
toast('Moved “'+nm+'”','ok'); refreshNavigation(); }
catch(err){ toast('move failed: '+((err&&err.message)||err),'err'); }
return;
}
if(M.mode==='goal'){ await navSendGoal(wx,wy,0); return; }
if(M.mode==='add'){
const name=(prompt('Add place at ('+wx.toFixed(2)+', '+wy.toFixed(2)+')\nName:','')||'').trim();
if(!name) return;
try{ await api('POST','/api/nav/save_at'+(mp?('?map='+encodeURIComponent(mp)):''),{name:name,x:wx,y:wy,yaw:0});
toast('Place “'+name+'” added','ok'); refreshNavigation(); }
catch(err){ toast('save failed: '+((err&&err.message)||err),'err'); }
return;
}
});
}
// ── Map Editor tab ───────────────────────────────────────────────
// Edit a SAVED map: erase phantom obstacles / paint virtual walls. Connects to
// /map_relay (which already bakes the saved overlay), paints NEW edits on top,
// and saves the full per-map overlay so the robot's costmap respects them.
let _med={ros:null,info:null,data:null,zoom:1,cache:null,dirty:true,started:false,
mapName:null,tool:'pan',edits:new Map(),history:[],batch:null,batchKeys:null,painting:false,bound:false};
function medSetS(m,ok){const s=document.getElementById('medStatus');if(s){s.textContent=m;s.style.color=ok?'#4ade80':'#f87171';}}
async function medRefreshMaps(){
try{const maps=await api('GET','/api/nav/maps');const arr=maps.maps||maps.items||(Array.isArray(maps)?maps:[]);
const sel=document.getElementById('medMapSelect'); if(!sel)return;
sel.innerHTML=arr.length?arr.map(m=>`<option value="${esc(m.path||m.name)}">${esc(m.name||m.path)} ${m.size_mb?('('+esc(m.size_mb)+'MB)'):''}</option>`).join(''):'<option disabled>no saved maps</option>';
}catch(e){}
}
function medSetTool(t){
_med.tool=t;
['pan','erase','wall'].forEach(x=>{const b=document.getElementById('medTool_'+x);if(b)b.classList.toggle('running',x===t);});
const cv=document.getElementById('medCanvas'); if(cv)cv.style.cursor=(t==='pan'?'default':'crosshair');
}
async function medLoad(b){
const sel=document.getElementById('medMapSelect'); const db=sel&&sel.value; if(!db){toast('No map selected','warn');return;}
_med.mapName=((db.split('/').pop()||'').replace(/\.db$/,''))||null;
const ml=document.getElementById('medMapLabel'); if(ml)ml.textContent=_med.mapName||'—';
if(b){b.classList.add('running');b.textContent='Loading…';}
try{ await api('POST','/api/nav/load_map',{db_path:db}); toast('Loading “'+(_med.mapName||'map')+'” — grid appears shortly','ok'); }
catch(e){ toast('load failed: '+(e.message||e),'err'); }
// pull the saved overlay so edits accumulate (don't overwrite on save)
_med.edits=new Map(); _med.history=[];
try{ const r=await api('GET','/api/nav/map_edits?map='+encodeURIComponent(_med.mapName));
((r&&r.edits)||[]).forEach(e=>{const wx=+e[0],wy=+e[1],v=+e[2];_med.edits.set(wx.toFixed(3)+','+wy.toFixed(3),{wx,wy,v});}); }catch(e){}
_med.started=false; medConnect(); medBindCanvas();
if(b){b.classList.remove('running');b.textContent='Load & Edit';}
medRefreshMode();
}
// Show what the single robot is doing, and whether the map being edited is the
// one currently live. Edits save per-map regardless, but they only PREVIEW live
// (bake into /map_relay) when the loaded map IS the active map.
async function medRefreshMode(){
const badge=document.getElementById('med-mode-badge'); if(!badge)return;
try{
const s=await api('GET','/api/nav/status');
window._navModeStatus=s;
const ml=s.mode_label||(s.bringup_alive?'running':'idle');
let ok=(s.mapping||s.localizing)?true:null, txt='MODE: '+ml;
// If editing a different map than the live one, flag that edits won't preview.
if(_med&&_med.mapName&&s.active_map&&s.active_map!==_med.mapName){
ok=null; txt='MODE: '+ml+' — editing “'+_med.mapName+'” (not live)';
}
badge.textContent=txt; badge.className='badge '+(ok===true?'badge-ok':ok===false?'badge-err':'');
}catch(e){ badge.textContent='MODE —'; badge.className='badge'; }
}
function medConnect(){
medBindCanvas();
if(_med.started)return; _med.started=true;
if(_med.ros){try{_med.ros.removeAllListeners&&_med.ros.removeAllListeners();}catch(e){}try{_med.ros.close();}catch(e){}_med.ros=null;}
_med.started=true;
const url=(typeof navRosbridgeUrl==='function')?navRosbridgeUrl():('ws://'+location.hostname+':9090');
if(typeof ROSLIB==='undefined'){medSetS('roslib not loaded',false);return;}
const ros=new ROSLIB.Ros({url}); _med.ros=ros;
ros.on('connection',()=>medSetS('● live '+url,true));
ros.on('close',()=>{medSetS('● closed — retry 3s',false);_med.started=false;setTimeout(medConnect,3000);});
ros.on('error',()=>medSetS('● rosbridge error',false));
const onGrid=m=>{_med.info=m.info;_med.data=m.data;_med.dirty=true;medRender();};
new ROSLIB.Topic({ros,name:'/map_relay',messageType:'nav_msgs/msg/OccupancyGrid'}).subscribe(onGrid);
new ROSLIB.Topic({ros,name:'/map',messageType:'nav_msgs/msg/OccupancyGrid'}).subscribe(onGrid);
}
function medRender(){
const cv=document.getElementById('medCanvas'),M=_med; if(!cv||!M.info||!M.data)return;
const ctx=cv.getContext('2d'),W=M.info.width,H=M.info.height,z=M.zoom;
cv.width=W*z; cv.height=H*z;
if(M.dirty||!M.cache){M.cache=document.createElement('canvas');M.cache.width=W;M.cache.height=H;
const oc=M.cache.getContext('2d'),img=oc.createImageData(W,H);
for(let i=0;i<M.data.length;i++){const v=M.data[i];let r,g,b,a=255;
if(v===-1){r=46;g=52;b=64;a=160;}else if(v<25){r=210;g=216;b=226;}else if(v>75){r=15;g=17;b=21;}else{r=140;g=148;b=160;}
const x=i%W,y=H-1-Math.floor(i/W),idx=4*(y*W+x);img.data[idx]=r;img.data[idx+1]=g;img.data[idx+2]=b;img.data[idx+3]=a;}
oc.putImageData(img,0,0);M.dirty=false;}
ctx.imageSmoothingEnabled=false;ctx.drawImage(M.cache,0,0,W*z,H*z);
// edit overlay — target colour + yellow "edited" tint
const ox=M.info.origin.position.x,oy=M.info.origin.position.y,res=M.info.resolution;
M.edits.forEach(e=>{
const c=Math.floor((e.wx-ox)/res),r=Math.floor((e.wy-oy)/res);
if(c<0||c>=W||r<0||r>=H)return;
const px=c*z,py=(H-1-r)*z;
ctx.fillStyle=(e.v<=0)?'#d2d8e2':'#0f1115'; ctx.fillRect(px,py,z,z);
ctx.fillStyle='rgba(250,204,21,0.45)'; ctx.fillRect(px,py,z,z);
});
}
function medFitZoom(){
const M=_med,cv=document.getElementById('medCanvas');if(!M.info||!cv||!cv.parentElement)return 1;
const W=M.info.width,H=M.info.height;if(!W||!H)return 1;
const box=cv.parentElement,cs=getComputedStyle(box);
const px=(parseFloat(cs.paddingLeft)||0)+(parseFloat(cs.paddingRight)||0),py=(parseFloat(cs.paddingTop)||0)+(parseFloat(cs.paddingBottom)||0);
return Math.max(0.5,Math.min(16,Math.min((box.clientWidth-px)/W,(box.clientHeight-py)/H)));
}
function medZoom(d){const M=_med;if(d===0)M.zoom=medFitZoom();else M.zoom=Math.max(0.5,Math.min(16,M.zoom*(d>0?1.4:1/1.4)));medRender();}
function _medWorld(e,cv){const M=_med,rect=cv.getBoundingClientRect();
const cx=(e.clientX-rect.left)*(cv.width/rect.width),cy=(e.clientY-rect.top)*(cv.height/rect.height);
const ox=M.info.origin.position.x,oy=M.info.origin.position.y,res=M.info.resolution,z=M.zoom,H=M.info.height;
return [ox+(cx/z)*res, oy+(H-cy/z)*res];}
function medPaint(wx,wy){
const M=_med; if(!M.info||M.tool==='pan')return;
const res=M.info.resolution,ox=M.info.origin.position.x,oy=M.info.origin.position.y,W=M.info.width,H=M.info.height;
const c0=Math.floor((wx-ox)/res),r0=Math.floor((wy-oy)/res);
const k=(parseInt(document.getElementById('medBrush').value)-1)/2;
const v=M.tool==='erase'?0:100;
for(let dc=-k;dc<=k;dc++)for(let dr=-k;dr<=k;dr++){
const c=c0+dc,r=r0+dr; if(c<0||c>=W||r<0||r>=H)continue;
const cwx=ox+(c+0.5)*res,cwy=oy+(r+0.5)*res,key=cwx.toFixed(3)+','+cwy.toFixed(3);
if(M.batch&&!M.batchKeys.has(key)){M.batchKeys.add(key);M.batch.push({key,prev:M.edits.get(key)});}
M.edits.set(key,{wx:cwx,wy:cwy,v});
}
medRender();
}
function medBindCanvas(){
if(_med.bound)return; const cv=document.getElementById('medCanvas'); if(!cv)return; _med.bound=true;
const down=e=>{if(_med.tool==='pan'||!_med.info)return; e.preventDefault(); _med.painting=true; _med.batch=[]; _med.batchKeys=new Set(); const[wx,wy]=_medWorld(e,cv); medPaint(wx,wy);};
const move=e=>{if(!_med.painting)return; const[wx,wy]=_medWorld(e,cv); medPaint(wx,wy);};
const up=()=>{if(!_med.painting)return; _med.painting=false; if(_med.batch&&_med.batch.length)_med.history.push(_med.batch); _med.batch=null; _med.batchKeys=null;};
cv.addEventListener('mousedown',down); cv.addEventListener('mousemove',move);
window.addEventListener('mouseup',up);
}
function medUndo(){const b=_med.history.pop();if(!b)return;for(const {key,prev} of b){if(prev===undefined)_med.edits.delete(key);else _med.edits.set(key,prev);}medRender();}
function medClearEdits(){if(!_med.edits.size)return;if(!confirm('Discard all '+_med.edits.size+' edits on this map? (Save to make it permanent.)'))return;_med.edits=new Map();_med.history=[];medRender();toast('Edits cleared (not yet saved)','ok');}
async function medSave(b){
if(!_med.mapName){toast('Load a map first','warn');return;}
const edits=[]; _med.edits.forEach(e=>edits.push([e.wx,e.wy,e.v]));
if(b){b.classList.add('running');b.textContent='Saving…';}
try{ const r=await api('POST','/api/nav/map_edits?map='+encodeURIComponent(_med.mapName),{edits});
toast('Saved '+(r&&r.count!=null?r.count:edits.length)+' edits — robot will respect them','ok'); }
catch(e){ toast('save failed: '+(e.message||e),'err'); }
if(b){b.classList.remove('running');b.textContent='💾 Save';}
}
// Temperature tab — lazy-load the 3D iframe on first open so its WebSocket
// only connects when the user actually views it. Also wires the Controller tab:
// refresh on enter, and stop teleop (release the window key listeners) on leave.
// Terminal tab: lazy-init xterm on first open and re-fit on every entry so
// the shell lays out correctly after a tab switch.
(function(){
const origSwitchTab=window.switchTab;
window.switchTab=function(name){
origSwitchTab(name);
if(name!=='controller') ctrlStopTeleop(); // don't leave WASD bound to other tabs
if(name==='controller') refreshController();
if(name==='navigation'){ refreshNavigation(); navMapConnect(); }
if(name==='mapeditor'){ medRefreshMaps(); medBindCanvas(); if(_med.mapName) medConnect(); medRefreshMode(); }
if(name==='mask') refreshMask();
if(name==='settings') refreshStorage();
if(name==='temp'){
const f=document.getElementById('temp3d-frame');
if(f && (!f.src || /about:blank$/.test(f.src))){
f.src='/static/temp3d/index.html?v='+Date.now(); // cache-bust (static file)
}
refreshBattery();
}
if(name==='terminal'){
// Defer to next frame so the panel's display:flex has applied —
// FitAddon measures the host div and needs non-zero dimensions.
requestAnimationFrame(function(){ termInit(); termFitSafe(); if(termInstance) termInstance.focus(); });
}
};
})();
// Battery (BMS) widget on the Temperature tab.
async function refreshBattery(){
const soc=document.getElementById('batt-soc'); if(!soc) return;
try{
const b=await api('GET','/api/temp/battery');
const fill=document.getElementById('batt-fill');
const set=(id,v)=>{const e=document.getElementById(id);if(e)e.textContent=v;};
if(!b || !b.available){
soc.textContent='--%';
set('batt-status','No BMS data');
if(fill){fill.style.width='0%';fill.classList.remove('charging');}
['batt-volt','batt-cur','batt-temp','batt-cycle'].forEach(id=>set(id,'--'));
set('batt-msg','Waiting for battery topic (rt/lf/bmsstate)…');
return;
}
const pct=(b.soc!=null?b.soc:0);
soc.textContent=pct+'%';
if(fill){
fill.style.width=pct+'%';
fill.style.background = pct>50?'#22c55e' : pct>20?'#f59e0b' : '#ef4444';
fill.classList.toggle('charging', b.status==='charging');
}
set('batt-status', b.status==='charging'?'⚡ Charging' : b.status==='discharging'?'Discharging' : 'Idle');
set('batt-volt', b.voltage_v!=null?(b.voltage_v+' V'):'--');
set('batt-cur', b.current_a!=null?(b.current_a+' A'):'--');
set('batt-temp', b.temp_c!=null?(b.temp_c+' °C'):'--');
set('batt-cycle',b.cycle!=null?b.cycle:'--');
set('batt-msg', (b.soh?('Health '+b.soh+'% · '):'')+'SOC = charge remaining'+(b.age_sec!=null?(' · updated '+b.age_sec+'s ago'):''));
}catch(e){}
}
// Init — vision/camera/detector fetches removed; those endpoints were deleted.
refreshStatus();refreshSystem();refreshAudio();refreshAudioDevices();refreshSkills();refreshReplayFiles();refreshScripts();refreshPrompt();refreshRecords();refreshLiveVoice();refreshLiveSub();refreshTR();refreshWakeActions();refreshApiKey();refreshCombo();refreshRecognition();refreshZones();refreshPlaybackStatus();refreshStatusStrip();refreshStorage();refreshBattery();refreshSysLive();navInitHost();navLoadConfig();refreshNavigation();connectLogs();
setTimeout(autoConnectGemini,2000);setTimeout(autoStartLiveSub,3000);
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);setInterval(refreshRecognition,5000);setInterval(refreshPlaybackStatus,1000);setInterval(tickPlayback,120);setInterval(refreshBattery,6000);setInterval(refreshSysLive,8000);
setInterval(refreshStatusStrip,2500);
setInterval(function(){const t=document.getElementById('tab-controller');if(t&&t.classList.contains('active'))refreshController();},2000);
setInterval(function(){const t=document.getElementById('tab-navigation');if(t&&t.classList.contains('active'))refreshNavigation();},2000);
setInterval(function(){const t=document.getElementById('tab-mapeditor');if(t&&t.classList.contains('active')&&typeof medRefreshMode==='function')medRefreshMode();},2500);
setInterval(function(){const t=document.getElementById('tab-mask');if(t&&t.classList.contains('active'))refreshMask();},3000);
// Safety: if the tab loses focus / is hidden while teleoping, a keyup can be
// missed and a key would "stick" (robot keeps moving). Stop teleop on blur/hide.
window.addEventListener('blur',function(){ ctrlStopTeleop(); });
document.addEventListener('visibilitychange',function(){ if(document.hidden) ctrlStopTeleop(); });
</script>
</body>
</html>