feat(gui): right_arm.js — auto-refresh, detect, move, home, custom
This commit is contained in:
1 parent
03ecbbae4e
commit
8297abb61d
2 files changed
+387
-1
No files matched your search
@@ -107,7 +107,7 @@ def index() -> HTMLResponse:
|
|||||||
idx = WEB_DIR / "index.html"
|
idx = WEB_DIR / "index.html"
|
||||||
html = idx.read_text(encoding="utf-8")
|
html = idx.read_text(encoding="utf-8")
|
||||||
# Cache-bust app.js and style.css with their mtime so a rebuild forces refetch.
|
# 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
|
f = WEB_DIR / name
|
||||||
if f.exists():
|
if f.exists():
|
||||||
v = int(f.stat().st_mtime)
|
v = int(f.stat().st_mtime)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user