Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
763 lines
28 KiB
JavaScript
763 lines
28 KiB
JavaScript
/* 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} · ${r.build_time}`; pill.title = `versione ${r.version} · 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();
|
||
})();
|