/* presa-mano GUI */ const $ = (id) => document.getElementById(id); // ---------- tab switching ---------- document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $('tab-' + btn.dataset.tab).classList.add('active'); }); }); function setStatus(msg, cls = '') { const strip = $('statusStrip'); strip.textContent = msg || ''; strip.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)'; } // ---------- button flash + toast feedback ---------- // Used by every command-sending handler so the user sees an immediate, // unambiguous success/failure signal (beyond the log panels that require // scrolling). Call flashBtn(btn, ok) to flash the button, and toast(title, // body, ok) to show a corner notification. function flashBtn(btn, ok) { if (!btn || !btn.classList) return; btn.classList.remove('flash-ok', 'flash-err'); // restart animation void btn.offsetWidth; btn.classList.add(ok ? 'flash-ok' : 'flash-err'); setTimeout(() => btn.classList.remove(ok ? 'flash-ok' : 'flash-err'), ok ? 900 : 1300); } function toast(title, body, ok = true, ttlMs = 3500) { const host = $('toastHost'); if (!host) return; const el = document.createElement('div'); el.className = 'toast ' + (ok ? 'ok' : 'err'); const t = document.createElement('div'); t.className = 't-title'; t.textContent = (ok ? '✓ ' : '✗ ') + title; el.appendChild(t); if (body) { const b = document.createElement('div'); b.className = 't-body'; b.textContent = typeof body === 'string' ? body : JSON.stringify(body); el.appendChild(b); } host.appendChild(el); setTimeout(() => { el.classList.add('fade'); }, ttlMs - 400); setTimeout(() => { el.remove(); }, ttlMs); } // Short synthetic beep via Web Audio — no external asset. Used to audibly flag // new anomalies that flip the system from OK -> WARN/ERR. Gated by the // `diagBeep` checkbox so users can silence it. function beep(freq = 880, ms = 140, gain = 0.18) { if (!window.AudioContext && !window.webkitAudioContext) return; try { const ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)()); const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; g.gain.value = gain; osc.connect(g); g.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + ms / 1000); } catch {} } function beepWarn() { beep(880, 130); } function beepErr() { beep(440, 150); setTimeout(() => beep(330, 180), 170); } // Summarise a successful response in one short line — prefer "dry-run" hint // or response 'ok', else truncated JSON. function summarizeOk(resp) { if (!resp || typeof resp !== 'object') return ''; if (resp.dry_run) return 'dry-run (nessun comando reale inviato)'; if (resp.ok === true && Object.keys(resp).length <= 3) return 'ok'; const short = JSON.stringify(resp); return short.length > 140 ? short.slice(0, 140) + '…' : short; } function fmtTimings(t) { if (!t || typeof t !== 'object') return ''; // Put 'total' last; others in insertion order. const keys = Object.keys(t).filter(k => k !== 'total'); const parts = keys.map(k => `${k}=${Number(t[k]).toFixed(1)}ms`); if (t.total != null) parts.push(`TOTAL=${Number(t.total).toFixed(1)}ms`); return parts.join(' '); } async function api(method, path, body = null) { const opts = { method, headers: {} }; if (body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); const text = await r.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!r.ok) { const detail = (data && data.detail) || text || r.statusText; throw new Error(`${r.status} ${detail}`); } return data; } // ==================== TROVA UTENSILE ==================== const graspCanvas = $('graspCanvas'); const graspCtx = graspCanvas.getContext('2d'); let displayedGrasp = null; let graspRgbInFlight = false; // (rimossi cropCanvas/scatter — ora c'è findResultCanvas) let lastDetections = []; // all detections, sorted by confidence desc let lastSelectedClass = null; // class name whose point cloud is displayed let knownClasses = []; // names discovered from /api/perception/classes // Stable per-class color palette (Okabe-Ito, colour-blind safe). const CLASS_PALETTE = ['#4da3ff', '#f39c12', '#2ecc71', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#fd79a8']; function colorForClass(name) { if (!name) return '#888'; let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; return CLASS_PALETTE[Math.abs(h) % CLASS_PALETTE.length]; } function drawGraspFrame() { if (displayedGrasp) { if (graspCanvas.width !== displayedGrasp.naturalWidth) { graspCanvas.width = displayedGrasp.naturalWidth; graspCanvas.height = displayedGrasp.naturalHeight; } graspCtx.drawImage(displayedGrasp, 0, 0); } lastDetections.forEach(d => { const b = d.bbox; const color = colorForClass(d.class); const isSel = lastSelectedClass && d.class === lastSelectedClass; graspCtx.strokeStyle = color; graspCtx.lineWidth = isSel ? 4 : 2; graspCtx.setLineDash(isSel ? [] : [6, 4]); graspCtx.strokeRect(b.x1, b.y1, b.x2 - b.x1, b.y2 - b.y1); graspCtx.setLineDash([]); const label = `${d.class || '?'} ${(d.confidence*100).toFixed(0)}%${isSel ? ' ★' : ''}`; graspCtx.font = 'bold 13px sans-serif'; const tw = graspCtx.measureText(label).width + 8; const ty = b.y1 > 18 ? b.y1 - 4 : b.y1 + 16; graspCtx.fillStyle = color; graspCtx.fillRect(b.x1, ty - 14, tw, 18); graspCtx.fillStyle = '#0b0f14'; graspCtx.fillText(label, b.x1 + 4, ty); }); } function refreshGraspRgb() { if (graspRgbInFlight) return; graspRgbInFlight = true; const img = new Image(); img.onload = () => { displayedGrasp = img; graspRgbInFlight = false; drawGraspFrame(); }; img.onerror = () => { graspRgbInFlight = false; }; img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`; } setInterval(() => { if ($('tab-grasp').classList.contains('active')) refreshGraspRgb(); }, 150); // click-to-measure on RGB graspCanvas.addEventListener('click', async (ev) => { const rect = graspCanvas.getBoundingClientRect(); const u = Math.round((ev.clientX - rect.left) * (graspCanvas.width / rect.width)); const v = Math.round((ev.clientY - rect.top) * (graspCanvas.height / rect.height)); $('measureInfo').textContent = `measuring (u=${u}, v=${v})…`; try { const r = await api('GET', `/api/perception/measure?u=${u}&v=${v}&radius=6`); const cam = r.camera || {}; let line = `(u=${u}, v=${v}) cam: `; const xyz = (o) => o && (o.x !== undefined ? `x=${(+o.x).toFixed(3)} y=${(+o.y).toFixed(3)} z=${(+o.z).toFixed(3)}` : JSON.stringify(o)); line += xyz(cam.point || cam.xyz || cam); const d = cam.distance_m ?? cam.distance ?? null; if (d != null) line += ` d=${(+d*100).toFixed(1)} cm`; if (r.base) line += ` base: ${xyz(r.base)}`; const t = fmtTimings(r.timings_ms); if (t) line += ` [${t}]`; $('measureInfo').textContent = line; // marker graspCtx.fillStyle = '#e74c3c'; graspCtx.beginPath(); graspCtx.arc(u, v, 5, 0, 2*Math.PI); graspCtx.fill(); } catch (e) { $('measureInfo').textContent = `measure error: ${e.message}`; } }); function pickedClass() { const v = $('graspObjectPick').value; return v ? v : null; } // Apply a /scan response to local state + canvases. function applyScanResponse(r) { lastDetections = Array.isArray(r.detections) ? r.detections : []; const sel = r.selected || null; lastSelectedClass = sel ? sel.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); } $('detectBtn').addEventListener('click', async () => { try { setStatus('YOLO + pointcloud…'); const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); applyScanResponse(r); $('graspOut').textContent = JSON.stringify({ detections: r.detections, selected: r.selected, n_points: r.n_points, frame: r.points_base ? 'base' : 'camera', timings_ms: r.timings_ms, }, null, 2); const nDet = r.detections ? r.detections.length : 0; setStatus(`${nDet} detection · ${r.n_points || 0} punti · ${fmtTimings(r.timings_ms)}`, 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // --- continuous detection loop --- const STATS_WINDOW = 5; const MISS_LIMIT = 5; // consecutive frames without detection before clearing the view let loopTimer = null; let loopInFlight = false; let recentCentroids = []; // [[x,y,z], ...] in base frame if calibrated, else camera let missStreak = 0; function clearDetectionState(reason) { lastDetections = []; lastSelectedClass = null; recentCentroids = []; drawGraspFrame(); // redraws RGB without bbox overlay updateStats(); // resets table to dashes refreshObjectPickOptions([]); renderGraspLegend([]); $('graspOut').textContent = reason || '(nessuna detection)'; } function centroidOf(pts) { if (!pts || !pts.length) return null; let sx = 0, sy = 0, sz = 0; for (const p of pts) { sx += p[0]; sy += p[1]; sz += p[2]; } return [sx / pts.length, sy / pts.length, sz / pts.length]; } function updateStats() { const rows = $('statsTable').querySelectorAll('tbody tr'); const empty = ['—','—','—','—','—']; $('statsWindow').textContent = STATS_WINDOW; if (recentCentroids.length === 0) { rows.forEach(r => { r.children[1].textContent='—'; r.children[2].textContent='—'; r.children[3].textContent='—'; r.children[4].textContent='—'; r.children[5].textContent='—'; }); return; } const n = recentCentroids.length; for (let axis = 0; axis < 3; axis++) { const vals = recentCentroids.map(c => c[axis]); const mean = vals.reduce((a,b) => a+b, 0) / n; const vmin = Math.min(...vals), vmax = Math.max(...vals); const variance = n > 1 ? vals.reduce((s,v) => s + (v-mean)*(v-mean), 0) / (n - 1) : 0; const std = Math.sqrt(variance); const r = rows[axis]; r.children[1].textContent = mean.toFixed(4); r.children[2].textContent = (std * 1000).toFixed(2); r.children[3].textContent = ((vmax - vmin) * 1000).toFixed(2); r.children[4].textContent = vmin.toFixed(4); r.children[5].textContent = vmax.toFixed(4); } } async function loopTick() { if (loopInFlight) return; loopInFlight = true; const t0 = performance.now(); try { const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); const pts = r.points_base || r.points_camera || []; const frame = r.points_base ? 'base' : 'camera'; lastDetections = Array.isArray(r.detections) ? r.detections : []; lastSelectedClass = r.selected ? r.selected.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); const c = centroidOf(pts); if (c) { recentCentroids.push(c); if (recentCentroids.length > STATS_WINDOW) recentCentroids.shift(); updateStats(); } const nDet = lastDetections.length; if (nDet === 0) throw new Error('404 no object detected'); missStreak = 0; const dt = performance.now() - t0; const selMsg = r.selected ? `sel=${lastSelectedClass}` : (pickedClass() ? `sel=${pickedClass()} NON rilevata` : 'sel=—'); $('statsMeta').textContent = `loop ON · ${nDet} detection · ${selMsg} · frame=${frame} · n=${r.n_points} · iter ${dt.toFixed(0)}ms · ${fmtTimings(r.timings_ms)}`; $('statsMeta').style.color = r.selected ? 'var(--ok)' : 'var(--warn)'; } catch (e) { const msg = e.message || ''; // Classify: // - true miss (404/422): object not detected → increments miss streak // - transient upstream (500/502/503 from YOLO/Nuwa): skip silently, no streak bump // - anything else: surface as real error const isMiss = /^404\b|no object|^422\b|too few/i.test(msg); const isTransient = /^(500|502|503)\b|bad gateway|service unavailable|temporarily/i.test(msg); if (isMiss) { missStreak++; if (missStreak >= MISS_LIMIT) { clearDetectionState(`nessun oggetto per ${MISS_LIMIT} frame consecutivi`); $('statsMeta').textContent = `loop ON · nessun oggetto per ${MISS_LIMIT} frame → dati azzerati`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop ON · miss ${missStreak}/${MISS_LIMIT}`; $('statsMeta').style.color = 'var(--warn)'; } } else if (isTransient) { $('statsMeta').textContent = `loop ON · upstream busy (skip): ${msg}`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop error: ${msg}`; $('statsMeta').style.color = 'var(--danger)'; } } finally { loopInFlight = false; } } function startLoop() { if (loopTimer) return; const rate = Math.max(100, +$('loopRateMs').value || 500); missStreak = 0; recentCentroids = []; updateStats(); loopTick(); loopTimer = setInterval(loopTick, rate); const b = $('loopBtn'); b.textContent = '■ Stop loop'; b.classList.add('running'); $('statsMeta').textContent = 'loop ON'; $('statsMeta').style.color = 'var(--ok)'; } function stopLoop() { if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } const b = $('loopBtn'); b.textContent = '▶ Loop riconoscimento'; b.classList.remove('running'); $('statsMeta').textContent = 'loop inattivo'; $('statsMeta').style.color = 'var(--dim)'; } $('loopBtn').addEventListener('click', () => (loopTimer ? stopLoop() : startLoop())); // If user leaves the tab, pause to avoid background load document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab !== 'grasp' && loopTimer) stopLoop(); })); // --- object pick select + legend + class discovery --- // Build per-class summary: best (highest-confidence) detection and instance count. function bestPerClass(dets) { const byClass = new Map(); const counts = new Map(); for (const d of dets) { if (!d.class) continue; counts.set(d.class, (counts.get(d.class) || 0) + 1); const cur = byClass.get(d.class); if (!cur || d.confidence > cur.confidence) byClass.set(d.class, d); } return { byClass, counts }; } function refreshObjectPickOptions(dets) { const sel = $('graspObjectPick'); const prev = sel.value; // known classes (from /classes, if any) keep a stable entry order; // detected-only classes are added after. const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); sel.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = '(top overall)'; sel.appendChild(opt0); for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const o = document.createElement('option'); o.value = name; const suffix = n > 1 ? ` ×${n}` : ''; o.textContent = d ? `${name} · ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} · —`; if (!d) o.style.color = '#888'; sel.appendChild(o); } // Preserve the previous selection if still valid, else fall back to '(top)'. const stillAvailable = prev === '' || ordered.includes(prev); sel.value = stillAvailable ? prev : ''; } function renderGraspLegend(dets) { const host = $('graspLegend'); if (!host) return; const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); host.innerHTML = ''; if (ordered.length === 0) { host.textContent = '(nessuna classe)'; return; } for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const item = document.createElement('span'); item.className = 'item' + (name === lastSelectedClass ? ' selected' : '') + (!d ? ' missing' : ''); const sw = document.createElement('span'); sw.className = 'sw'; sw.style.background = colorForClass(name); item.appendChild(sw); const suffix = n > 1 ? ` ×${n}` : ''; item.appendChild(document.createTextNode( d ? `${name} ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} (non rilevata)` )); host.appendChild(item); } } async function loadKnownClasses() { try { const r = await api('GET', '/api/perception/classes'); knownClasses = (r.classes || []).map(c => c.name).filter(Boolean); } catch (_e) { knownClasses = []; } refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); } loadKnownClasses(); // Changing the pick while the loop is idle → fire a one-off scan so the cloud // updates immediately. While looping, the next tick picks it up automatically. $('graspObjectPick').addEventListener('change', () => { renderGraspLegend(lastDetections); drawGraspFrame(); if (!loopTimer) $('detectBtn').click(); }); // ==================== FIT MODELLO ==================== const findCanvas = $('findResultCanvas'); const findCtx = findCanvas.getContext('2d'); let findResult = null; let findRgbImg = null; async function loadRecipes() { const sel = $('findModelPick'); try { const r = await api('GET', '/api/models/recipes'); sel.innerHTML = ''; (r.recipes || []).forEach(rec => { const opt = document.createElement('option'); opt.value = rec.id; opt.textContent = `${rec.name} (${rec.symmetry_type})`; sel.appendChild(opt); }); sel.disabled = (r.recipes || []).length === 0; $('findBtn').disabled = !sel.value; } catch (e) { sel.innerHTML = ``; } } $('findModelPick').addEventListener('change', () => { $('findBtn').disabled = !$('findModelPick').value; }); $('findRecipesRefreshBtn').addEventListener('click', loadRecipes); $('findBtn').addEventListener('click', async () => { const recipeId = $('findModelPick').value; if (!recipeId) return; $('findBtn').disabled = true; $('findStatus').textContent = 'Cerco posa…'; $('findStatus').className = 'status-line'; try { const body = { recipe_id: recipeId, conf: +$('graspConf').value || null, class_name: pickedClass(), }; const r = await api('POST', '/api/grasp/find_model', body); findResult = r; findRgbImg = new Image(); findRgbImg.onload = drawFindResult; findRgbImg.src = 'data:image/jpeg;base64,' + r.rgb_jpeg_b64; const fit = r.fit_ok ? `OK · fitness=${(r.fitness ?? 0).toFixed(2)} · rmse=${(r.inlier_rmse_mm ?? 0).toFixed(2)}mm` : `Fit fallito: ${r.fit_reason}`; $('findStatus').textContent = `${fit} · ${fmtTimings(r.timings_ms)}`; $('findStatus').className = 'status-line ' + (r.fit_ok ? 'ok' : 'warn'); $('findDetails').textContent = JSON.stringify({ detection: r.detection, recipe: r.recipe, n_accepted: r.n_accepted, n_excluded: r.n_excluded, fitness: r.fitness, rmse_mm: r.inlier_rmse_mm, timings: r.timings_ms, }, null, 2); } catch (e) { $('findStatus').textContent = `errore: ${e.message}`; $('findStatus').className = 'status-line err'; } finally { $('findBtn').disabled = !$('findModelPick').value; } }); ['findShowModel', 'findShowAccepted', 'findShowExcluded', 'findShowSam21', 'findShowPlane'].forEach(id => $(id).addEventListener('change', drawFindResult)); function drawFindResult() { if (!findResult || !findRgbImg || !findRgbImg.complete) return; const [W, H] = findResult.rgb_size; if (findCanvas.width !== W) { findCanvas.width = W; findCanvas.height = H; } findCtx.drawImage(findRgbImg, 0, 0); // Plane polygon (background, semi-transparent fill + outline). if ($('findShowPlane').checked) { const poly = findResult.plane_polygon_px || []; if (poly.length >= 3) { findCtx.beginPath(); findCtx.moveTo(poly[0][0], poly[0][1]); for (let i = 1; i < poly.length; i++) findCtx.lineTo(poly[i][0], poly[i][1]); findCtx.closePath(); findCtx.fillStyle = 'rgba(155,109,255,0.20)'; findCtx.fill(); findCtx.strokeStyle = '#9b6dff'; findCtx.lineWidth = 2; findCtx.stroke(); } } const dot = (pts, color, r) => { findCtx.fillStyle = color; for (const [u, v] of pts) findCtx.fillRect(u - r, v - r, r*2+1, r*2+1); }; // Draw order: SAM21 cluster (background) -> excluded -> accepted -> model // (foreground), so the Open3DModel STL projection wins over everything. if ($('findShowSam21').checked) dot(findResult.sam21_points_px || [], '#ffd83d', 1); if ($('findShowExcluded').checked) dot(findResult.excluded_points_px, '#e74c3c', 1); if ($('findShowAccepted').checked) dot(findResult.accepted_points_px, '#3ecf8e', 1); if ($('findShowModel').checked) dot(findResult.model_points_px, '#4da3ff', 1); } function loadRecipesIfNeeded() { if ($('tab-grasp').classList.contains('active') && !$('findModelPick').dataset.loaded) { loadRecipes(); $('findModelPick').dataset.loaded = '1'; } } document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => setTimeout(loadRecipesIfNeeded, 50))); loadRecipesIfNeeded(); // ==================== EE ==================== async function refreshWrist() { try { const r = await api('GET', '/api/wrist'); $('wristCurrent').textContent = JSON.stringify(r, null, 2); } catch (e) { $('wristCurrent').textContent = e.message; } } $('refreshWristBtn').addEventListener('click', refreshWrist); $('absSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, position: [+$('absPx').value, +$('absPy').value, +$('absPz').value], quaternion: [+$('absQx').value, +$('absQy').value, +$('absQz').value, +$('absQw').value], wait: true, timeout_s: 5.0, precise: false, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/absolute', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('relSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, delta_position: [+$('relDx').value, +$('relDy').value, +$('relDz').value], wait: true, timeout_s: 5.0, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/relative', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); const handBtn = (id, action) => $(id).addEventListener('click', async () => { try { const r = await api('POST', '/api/wrist/hand', { action, dry_run: $('eeDryRun').checked }); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); handBtn('handOpenBtn', 'RIGHT_OPEN'); handBtn('handCloseBtn', 'RIGHT_CLOSE'); handBtn('handOpenLBtn', 'LEFT_OPEN'); handBtn('handCloseLBtn', 'LEFT_CLOSE'); $('estopBtn').addEventListener('click', async () => { if (!confirm('EMERGENCY STOP: confermi?')) return; try { const r = await api('POST', `/api/wrist/emergency_stop?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('unlockBtn').addEventListener('click', async () => { try { const r = await api('POST', `/api/wrist/unlock?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); // ==================== HOME ==================== // Shows reachability + latency for every upstream microservice and lets the // user switch the active robot (G1 ↔ H1). The orchestrator runs the actual // switch via PUT /api/home/robot, which deep-merges into config.yaml. let homePollTimer = null; let homePollInFlight = false; function fmtLatency(ms) { if (ms == null) return '—'; if (ms < 1) return '<1 ms'; return `${Math.round(ms)} ms`; } function renderHomeStatus(data) { const tbody = document.querySelector('#homeDeviceTable tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!data || !Array.isArray(data.devices) || data.devices.length === 0) { tbody.innerHTML = 'nessun servizio configurato'; return; } for (const d of data.devices) { const tr = document.createElement('tr'); const status = d.ok ? 'ok' : 'err'; const icon = d.ok ? '✓' : '✗'; const detail = d.ok ? (d.status_code != null ? `HTTP ${d.status_code}` : 'reachable') : (d.error || (d.status_code != null ? `HTTP ${d.status_code}` : 'unreachable')); tr.innerHTML = ` ${d.label}${d.active ? ' attivo' : ''} ${d.url} ${icon} ${d.ok ? 'online' : 'offline'} ${fmtLatency(d.latency_ms)} ${detail} `; tbody.appendChild(tr); } const pill = $('activeRobotPill'); if (pill) pill.textContent = `robot: ${(data.active_robot || '—').toUpperCase()}`; } async function homePollOnce() { if (homePollInFlight) return; homePollInFlight = true; try { const r = await api('GET', '/api/home/status'); renderHomeStatus(r); } catch (e) { const tbody = document.querySelector('#homeDeviceTable tbody'); if (tbody) tbody.innerHTML = `errore: ${e.message}`; } finally { homePollInFlight = false; } } function startHomePoll() { stopHomePoll(); if (!$('homeAutoPoll').checked) return; const rate = Math.max(500, +$('homePollMs').value || 2000); homePollOnce(); homePollTimer = setInterval(homePollOnce, rate); } function stopHomePoll() { if (homePollTimer) { clearInterval(homePollTimer); homePollTimer = null; } } $('homeAutoPoll').addEventListener('change', startHomePoll); $('homePollMs').addEventListener('change', startHomePoll); $('homeRefreshBtn').addEventListener('click', homePollOnce); $('homeApplyRobotBtn').addEventListener('click', async (ev) => { const btn = ev.currentTarget; const target = $('homeRobotSelect').value; try { const r = await api('PUT', '/api/home/robot', { active: target }); $('homeLog').textContent = `robot attivo: ${r.active}`; $('homeLog').classList.remove('err'); flashBtn(btn, true); toast('Robot attivo', `${r.active.toUpperCase()}`, true); homePollOnce(); } catch (e) { $('homeLog').textContent = e.message; $('homeLog').classList.add('err'); flashBtn(btn, false); toast('Errore cambio robot', e.message, false); } }); // Sync the dropdown with whatever the backend currently reports as active. async function refreshHomeRobotSelector() { try { const r = await api('GET', '/api/home/robot'); if (r && r.active) $('homeRobotSelect').value = r.active; const pill = $('activeRobotPill'); if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`; } catch {} } refreshHomeRobotSelector(); startHomePoll(); // Badge versione build (header). Si aggiorna ad ogni rebuild via build.sh. async function refreshAppVersion() { try { const r = await api('GET', '/api/version'); const pill = $('appVersionPill'); if (pill) { pill.textContent = `V${r.version}`; pill.title = `build ${r.build_time}`; } } catch {} } refreshAppVersion(); // ---------- init ---------- (async () => { try { const cfg = await api('GET', '/api/config'); if ($('eeSide')) $('eeSide').value = cfg.robot.wrist_side; } catch {} refreshWrist(); })();