3762 lines
235 KiB
HTML
3762 lines
235 KiB
HTML
<!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 & 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 2–3 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 & 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°C → red ≈ 120°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 & 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 12–28</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 & 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 & 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 & 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 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 (~30–60s). Auto-reconnect keeps the face alive through BLE drops.</div>
|
||
</div>
|
||
|
||
<!-- Brightness & Text -->
|
||
<div class="card">
|
||
<h3>Brightness & 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 ~0–105, ANIM ids ~0–69 (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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||
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 Gemini’s 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,'"');
|
||
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 zone’s 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 won’t 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,'<');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>
|