diff --git a/src/presa_mano/main.py b/src/presa_mano/main.py index 9cf08a6..da7a172 100644 --- a/src/presa_mano/main.py +++ b/src/presa_mano/main.py @@ -107,7 +107,7 @@ def index() -> HTMLResponse: idx = WEB_DIR / "index.html" html = idx.read_text(encoding="utf-8") # Cache-bust app.js and style.css with their mtime so a rebuild forces refetch. - for name in ("app.js", "work_plane.js", "style.css"): + for name in ("app.js", "work_plane.js", "right_arm.js", "style.css"): f = WEB_DIR / name if f.exists(): v = int(f.stat().st_mtime) diff --git a/web/right_arm.js b/web/right_arm.js new file mode 100644 index 0000000..3ec5aaa --- /dev/null +++ b/web/right_arm.js @@ -0,0 +1,386 @@ +// 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; + 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(); +}