Files
Presa-Mano/web/app.js
T
AdrianoandClaude Opus 4.8 cf726b479e feat: badge versione build in GUI + build.sh con auto-bump
- VERSION (1.0.0) + build.sh: bump patch/minor/major e deploy via docker compose
- Dockerfile: ARG/ENV APP_VERSION + BUILD_TIME (iniettati come build-arg)
- version.build_info(): env in Docker, fallback file VERSION + '(local)' in dev
- GET /api/version; badge #appVersionPill nell'header (tooltip = build time)
- test_version.py: env vs fallback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:40:52 +02:00

763 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = '<option value="">— seleziona modello —</option>';
(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 = `<option value="">errore: ${e.message}</option>`;
}
}
$('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 = '<tr><td colspan="5" class="dim">nessun servizio configurato</td></tr>';
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 = `
<td>${d.label}${d.active ? ' <span class="badge ok">attivo</span>' : ''}</td>
<td><code>${d.url}</code></td>
<td><span class="device-pill ${status}">${icon} ${d.ok ? 'online' : 'offline'}</span></td>
<td>${fmtLatency(d.latency_ms)}</td>
<td>${detail}</td>
`;
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 = `<tr><td colspan="5" class="err">errore: ${e.message}</td></tr>`;
} 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();
})();