// Tab "Braccio DX" — auto-refresh immagini, YOLO on-demand, move-toward, // posa Home/Custom. Vanilla JS, niente dipendenze. const $ = (id) => document.getElementById(id); const FRONT_REFRESH_MS = 1000; const WRIST_REFRESH_MS = 1000; const POSE_REFRESH_MS = 2000; let frontTimer = null; let wristTimer = null; let poseTimer = null; // ---------------- Helpers ---------------- async function jsonFetch(url, opts) { const r = await fetch(url, opts); const body = await r.json().catch(() => ({})); if (!r.ok) { const err = new Error(body.detail || `HTTP ${r.status}`); err.status = r.status; throw err; } return body; } function setText(id, v) { const el = $(id); if (el) el.textContent = v; } function drawBbox(canvas, img, bbox, label) { if (!canvas || !img || !bbox) return; canvas.width = img.naturalWidth || img.width; canvas.height = img.naturalHeight || img.height; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const x = bbox.x1, y = bbox.y1, w = bbox.x2 - bbox.x1, h = bbox.y2 - bbox.y1; ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 3; ctx.strokeRect(x, y, w, h); if (label) { ctx.fillStyle = '#00ff00'; ctx.font = '14px monospace'; ctx.fillText(label, x, y - 4); } } function clearCanvas(canvas) { if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } // ---------------- Banner stato ---------------- async function refreshBanner() { let state; try { state = await jsonFetch('/api/right_arm/state'); } catch (e) { $('raStatusLabel').textContent = `Errore stato: ${e.message}`; $('raStatusBanner').hidden = false; $('raStatusBanner').className = 'ra-banner ra-banner-warn'; return; } const ok = state.robot_active === 'g1' && state.side === 'right'; const banner = $('raStatusBanner'); if (ok) { banner.hidden = true; } else { banner.hidden = false; banner.className = 'ra-banner ra-banner-warn'; $('raStatusLabel').textContent = `Robot attivo: ${state.robot_active} / ${state.side}. Per usare questa tab serve G1 / right.`; } return state; } async function onActivateClick() { try { await jsonFetch('/api/right_arm/activate', { method: 'POST' }); await refreshBanner(); await refreshPose(); } catch (e) { alert(`Attivazione fallita: ${e.message}`); } } // ---------------- Immagini ---------------- function urlWithBust(path) { return `${path}?t=${Date.now()}`; } function startFrontLoop() { if (frontTimer) clearInterval(frontTimer); const tick = () => { $('raFrontImg').src = urlWithBust('/api/right_arm/snapshot/front'); }; tick(); if ($('raFrontAutoRefresh').checked) { frontTimer = setInterval(tick, FRONT_REFRESH_MS); } } function startWristLoop() { if (wristTimer) clearInterval(wristTimer); const tick = () => { $('raWristImg').src = urlWithBust('/api/right_arm/snapshot/wrist'); }; tick(); if ($('raWristAutoRefresh').checked) { wristTimer = setInterval(tick, WRIST_REFRESH_MS); } } // ---------------- YOLO classes ---------------- async function populateClassDropdowns() { let classes = []; try { classes = await jsonFetch('/api/perception/yolo/classes'); if (Array.isArray(classes.classes)) classes = classes.classes; } catch (_) { classes = []; } const cfg = await jsonFetch('/api/config').catch(() => ({})); const dflt = (cfg.tab_right_arm && cfg.tab_right_arm.yolo_class) || 'pestello'; for (const id of ['raFrontClass', 'raWristClass']) { const sel = $(id); sel.innerHTML = ''; const names = classes.length ? classes.map(c => c.name || c) : [dflt]; for (const n of names) { const opt = document.createElement('option'); opt.value = n; opt.textContent = n; if (n === dflt) opt.selected = true; sel.appendChild(opt); } } if (cfg.tab_right_arm) { const cf = cfg.tab_right_arm.yolo_conf_front; const cw = cfg.tab_right_arm.yolo_conf_wrist; $('raFrontConf').value = (cf === undefined || cf === null) ? 0.4 : cf; $('raWristConf').value = (cw === undefined || cw === null) ? 0.4 : cw; } } // ---------------- Detect on-demand ---------------- async function onDetectFront() { const className = $('raFrontClass').value; const conf = parseFloat($('raFrontConf').value) || null; const proxy = window.location.origin; try { const res = await jsonFetch('/api/right_arm/detect/front', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ class_name: className, conf, proxy_base_url: proxy }), }); const top = res.detections[0]; drawBbox($('raFrontCanvas'), $('raFrontImg'), top.bbox, `${top.class} ${(top.confidence * 100).toFixed(0)}%`); $('raFrontResult').classList.remove('dim'); $('raFrontResult').textContent = `classe: ${top.class} conf: ${top.confidence.toFixed(2)} ` + `bbox: [${Math.round(top.bbox.x1)},${Math.round(top.bbox.y1)},` + `${Math.round(top.bbox.x2)},${Math.round(top.bbox.y2)}]`; } catch (e) { clearCanvas($('raFrontCanvas')); $('raFrontResult').textContent = `Errore: ${e.message}`; } } async function onDetectWrist() { const className = $('raWristClass').value; const conf = parseFloat($('raWristConf').value) || null; try { const res = await jsonFetch('/api/right_arm/detect/wrist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ class_name: className, conf }), }); drawBbox($('raWristCanvas'), $('raWristImg'), res.detection.bbox, `${res.detection.class} ${(res.detection.confidence * 100).toFixed(0)}%`); const c = res.centroid_camera_m; $('raWristResult').classList.remove('dim'); $('raWristResult').textContent = `classe: ${res.detection.class} conf: ${res.detection.confidence.toFixed(2)}\n` + `centroide cam (m): x=${c[0].toFixed(3)} y=${c[1].toFixed(3)} z=${c[2].toFixed(3)}\n` + `distanza: ${(res.distance_m * 1000).toFixed(0)} mm ` + `punti SAM21: ${res.n_sam21_points}`; } catch (e) { clearCanvas($('raWristCanvas')); $('raWristResult').textContent = `Errore: ${e.message}`; } } // ---------------- Posa polso ---------------- async function refreshPose() { try { const s = await jsonFetch('/api/right_arm/state'); const p = s.wrist.position; const r = s.wrist.rpy_deg; setText('raPoseX', p[0].toFixed(3)); setText('raPoseY', p[1].toFixed(3)); setText('raPoseZ', p[2].toFixed(3)); setText('raPoseR', r[0].toFixed(1)); setText('raPoseP', r[1].toFixed(1)); setText('raPoseW', r[2].toFixed(1)); // Aggiorna anche i campi delle pose (solo se non sono stati toccati) if (s.home_pose) fillPoseFields('raHome', s.home_pose); if (s.custom_pose) fillPoseFields('raCust', s.custom_pose); if (s.offset_camera_mm) { $('raOffX').value = s.offset_camera_mm.x; $('raOffY').value = s.offset_camera_mm.y; $('raOffZ').value = s.offset_camera_mm.z; } if (s.axis_signs) { $('raSgnX').value = s.axis_signs.x; $('raSgnY').value = s.axis_signs.y; $('raSgnZ').value = s.axis_signs.z; } } catch (e) { // silenzioso: il polso può non essere disponibile } } function fillPoseFields(prefix, pose) { const xId = prefix + 'X'; if ($(xId).dataset.userTouched) return; $(xId).value = pose.position[0]; $(prefix + 'Y').value = pose.position[1]; $(prefix + 'Z').value = pose.position[2]; $(prefix + 'R').value = pose.rpy_deg[0]; $(prefix + 'P').value = pose.rpy_deg[1]; $(prefix + 'W').value = pose.rpy_deg[2]; } function readPoseFields(prefix) { return { position: [ parseFloat($(prefix + 'X').value), parseFloat($(prefix + 'Y').value), parseFloat($(prefix + 'Z').value), ], rpy_deg: [ parseFloat($(prefix + 'R').value), parseFloat($(prefix + 'P').value), parseFloat($(prefix + 'W').value), ], }; } // ---------------- Offset save ---------------- async function onSaveOffset() { try { await jsonFetch('/api/right_arm/offset', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ move_offset_camera_mm: { x: parseFloat($('raOffX').value), y: parseFloat($('raOffY').value), z: parseFloat($('raOffZ').value), }, axis_sign_cam_to_wrist: { x: parseInt($('raSgnX').value, 10), y: parseInt($('raSgnY').value, 10), z: parseInt($('raSgnZ').value, 10), }, }), }); alert('Offset salvato.'); } catch (e) { alert(`Errore salvataggio: ${e.message}`); } } // ---------------- Move toward ---------------- async function onMove() { const className = $('raWristClass').value; const conf = parseFloat($('raWristConf').value) || null; const dryRun = $('raMoveDryRun').checked; if (!dryRun && !confirm('Dry-run OFF: eseguire move REALE verso il pezzo?')) return; try { const res = await jsonFetch('/api/right_arm/move/toward', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ class_name: className, conf, dry_run: dryRun }), }); const d = res.delta_wrist_mm; $('raMoveResult').classList.remove('dim'); $('raMoveResult').textContent = `delta polso (mm): X=${d[0].toFixed(1)} Y=${d[1].toFixed(1)} Z=${d[2].toFixed(1)} ` + `distanza: ${(res.distance_m * 1000).toFixed(0)} mm ` + `applicato: ${res.applied} z_clipped: ${res.z_clipped}`; if (!dryRun) await refreshPose(); } catch (e) { $('raMoveResult').textContent = `Errore: ${e.message}`; } } // ---------------- Home / Custom ---------------- async function onHome() { const dryRun = false; // home è "safe": default false if (!confirm('Eseguire move verso posa Home? (no dry-run)')) return; try { const res = await jsonFetch('/api/right_arm/home', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: dryRun }), }); await refreshPose(); alert(`Home eseguita: applied=${res.applied}`); } catch (e) { alert(`Errore Home: ${e.message}`); } } async function onCustomGo() { if (!confirm('Eseguire move verso posa Custom?')) return; try { await jsonFetch('/api/right_arm/custom_pose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: false }), }); await refreshPose(); } catch (e) { alert(`Errore Custom: ${e.message}`); } } async function onCustomSave() { const pose = readPoseFields('raCust'); try { await jsonFetch('/api/right_arm/custom_pose', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pose), }); alert('Posa custom salvata in config.'); } catch (e) { alert(`Errore salvataggio: ${e.message}`); } } async function onCustomUseCurrent() { try { const s = await jsonFetch('/api/right_arm/state'); $('raCustX').value = s.wrist.position[0].toFixed(3); $('raCustY').value = s.wrist.position[1].toFixed(3); $('raCustZ').value = s.wrist.position[2].toFixed(3); $('raCustR').value = s.wrist.rpy_deg[0].toFixed(1); $('raCustP').value = s.wrist.rpy_deg[1].toFixed(1); $('raCustW').value = s.wrist.rpy_deg[2].toFixed(1); // Marca i campi come "user touched" così refreshPose non li sovrascrive for (const k of ['X','Y','Z','R','P','W']) $('raCust' + k).dataset.userTouched = '1'; } catch (e) { alert(`Errore lettura posa: ${e.message}`); } } // ---------------- Init ---------------- function markUserTouchedOnInput(prefix) { for (const k of ['X','Y','Z','R','P','W']) { const el = $(prefix + k); if (el) el.addEventListener('input', () => { el.dataset.userTouched = '1'; }); } } function init() { if (!$('tab-right-arm')) return; $('raActivateBtn').addEventListener('click', onActivateClick); $('raFrontDetectBtn').addEventListener('click', onDetectFront); $('raWristDetectBtn').addEventListener('click', onDetectWrist); $('raMoveBtn').addEventListener('click', onMove); $('raSaveOffsetBtn').addEventListener('click', onSaveOffset); $('raHomeBtn').addEventListener('click', onHome); $('raCustomGoBtn').addEventListener('click', onCustomGo); $('raCustomSaveBtn').addEventListener('click', onCustomSave); $('raCustomUseCurrentBtn').addEventListener('click', onCustomUseCurrent); $('raPoseRefreshBtn').addEventListener('click', refreshPose); $('raFrontAutoRefresh').addEventListener('change', startFrontLoop); $('raWristAutoRefresh').addEventListener('change', startWristLoop); markUserTouchedOnInput('raHome'); markUserTouchedOnInput('raCust'); populateClassDropdowns(); startFrontLoop(); startWristLoop(); refreshBanner(); refreshPose(); if (poseTimer) clearInterval(poseTimer); poseTimer = setInterval(refreshPose, POSE_REFRESH_MS); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }