Files
Presa-Mano/docs/superpowers/plans/2026-05-20-right-arm-tab.md
T
Adriano Dal PastroandClaude Opus 4.7 779f506701 docs(plan): implementation plan tab Braccio DX
13 task TDD per il nuovo tab: config, geometry, math puro, 6 endpoint
router /api/right_arm/*, markup HTML + CSS, JS module. Spec:
docs/superpowers/specs/2026-05-20-right-arm-tab-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:33:06 +02:00

74 KiB

Tab "Braccio DX" Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Aggiungere una tab "Braccio DX" alla GUI per operare il braccio destro di G1 con due camere (fronte fissa + Nuwa eye-in-hand sul polso): visione YOLO/SAM21 on-demand, move-toward con offset configurabile, home + custom pose.

Architecture: Un nuovo router /api/right_arm/* + un nuovo modulo JS lato GUI; nessun client nuovo (la front camera è Nuwa-like, riuso NuwaClient); logica geometrica del delta isolata in un modulo puro (right_arm/move.py) per testabilità.

Tech Stack: FastAPI, Pydantic v2, httpx, NumPy, OpenCV, pytest + unittest.mock (no respx — coerente con i test esistenti), vanilla HTML/JS/CSS (no librerie nuove).

Spec di riferimento: docs/superpowers/specs/2026-05-20-right-arm-tab-design.md


File Structure

Da creare:

  • src/presa_mano/right_arm/__init__.py — package marker
  • src/presa_mano/right_arm/move.py — pure functions: compute_delta_wrist, apply_z_floor, schema RPY conversions
  • src/presa_mano/api/right_arm_routes.py — router /api/right_arm/*
  • web/right_arm.js — modulo JS della tab
  • tests/test_geometry_rpy.py — unit test conversioni RPY↔quaternion
  • tests/test_right_arm_move.py — unit test math del delta + z-floor
  • tests/test_right_arm_routes.py — integration test endpoint (con unittest.mock)

Da modificare:

  • config.yaml — aggiunte services.front_camera e blocco tab_right_arm
  • src/presa_mano/geometry.py — aggiungere rpy_deg_to_quaternion_xyzw e quaternion_xyzw_to_rpy_deg
  • src/presa_mano/api/_deps.py — aggiungere front_camera() e helper tab_right_arm_cfg()
  • src/presa_mano/main.py — include right_arm_routes.router + aggiungere right_arm.js al cache-busting
  • web/index.html — nuova tab right-arm + markup pannello + script include

Task 1: Config — aggiungere services.front_camera e tab_right_arm

Files:

  • Modify: config.yaml

  • Test: tests/test_config_right_arm.py (nuovo)

  • Step 1: Write the failing test

Create tests/test_config_right_arm.py:

"""Verifica che config.yaml contenga le chiavi attese per la tab Braccio DX."""
from __future__ import annotations

import yaml
from pathlib import Path


CFG = Path(__file__).resolve().parents[1] / "config.yaml"


def _cfg() -> dict:
    return yaml.safe_load(CFG.read_text())


def test_front_camera_service_present():
    c = _cfg()
    fc = c["services"]["front_camera"]
    assert fc["base_url"].startswith("http")
    assert isinstance(fc.get("timeout_s"), (int, float))


def test_tab_right_arm_block_present():
    c = _cfg()
    t = c["tab_right_arm"]
    assert t["yolo_class"] == "pestello"
    assert 0 < t["yolo_conf_front"] <= 1
    assert 0 < t["yolo_conf_wrist"] <= 1
    off = t["move_offset_camera_mm"]
    assert set(off.keys()) == {"x", "y", "z"}
    sgn = t["axis_sign_cam_to_wrist"]
    assert all(sgn[k] in (-1, 1) for k in ("x", "y", "z"))
    assert isinstance(t["max_step_m"], (int, float))
    assert "z_floor_m" in t
    for k in ("home_pose", "custom_pose"):
        p = t[k]
        assert len(p["position"]) == 3
        assert len(p["rpy_deg"]) == 3
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_config_right_arm.py -v Expected: FAIL (KeyError: 'front_camera')

  • Step 3: Edit config.yaml

Add under services::

  front_camera:
    base_url: http://192.168.123.99:8878
    timeout_s: 5.0

Add a new top-level block:

tab_right_arm:
  yolo_class: pestello
  yolo_conf_front: 0.4
  yolo_conf_wrist: 0.4
  move_offset_camera_mm: { x: 0, y: 0, z: 100 }
  axis_sign_cam_to_wrist:  { x: 1, y: -1, z: -1 }
  max_step_m: 0.15
  z_floor_m: null
  home_pose:
    position:   [0.30, -0.20, 0.30]
    rpy_deg:    [0, 0, 0]
  custom_pose:
    position:   [0.30, -0.20, 0.30]
    rpy_deg:    [0, 0, 0]
  • Step 4: Run test to verify it passes

Run: uv run pytest tests/test_config_right_arm.py -v Expected: PASS (3 test)

  • Step 5: Commit
git add config.yaml tests/test_config_right_arm.py
git commit -m "feat(config): aggiungi services.front_camera e blocco tab_right_arm"

Task 2: Geometry — rpy_deg_to_quaternion_xyzw + quaternion_xyzw_to_rpy_deg

Files:

  • Modify: src/presa_mano/geometry.py

  • Test: tests/test_geometry_rpy.py (nuovo)

  • Step 1: Write the failing test

Create tests/test_geometry_rpy.py:

"""Roundtrip RPY (deg) ↔ quaternion xyzw."""
from __future__ import annotations

import numpy as np
import pytest

from presa_mano.geometry import (
    quaternion_xyzw_to_rpy_deg,
    rpy_deg_to_quaternion_xyzw,
)


def test_zero_rpy_is_identity_quat():
    q = rpy_deg_to_quaternion_xyzw(0.0, 0.0, 0.0)
    assert np.allclose(q, [0.0, 0.0, 0.0, 1.0], atol=1e-9)


def test_quat_identity_is_zero_rpy():
    rpy = quaternion_xyzw_to_rpy_deg([0.0, 0.0, 0.0, 1.0])
    assert np.allclose(rpy, [0.0, 0.0, 0.0], atol=1e-6)


@pytest.mark.parametrize("roll,pitch,yaw", [
    (10.0, 20.0, 30.0),
    (-45.0, 0.0, 90.0),
    (0.0, 60.0, -30.0),
    (15.0, -25.0, 80.0),
])
def test_roundtrip_rpy_quat_rpy(roll, pitch, yaw):
    q = rpy_deg_to_quaternion_xyzw(roll, pitch, yaw)
    r2, p2, y2 = quaternion_xyzw_to_rpy_deg(q)
    assert abs(r2 - roll) < 1e-4
    assert abs(p2 - pitch) < 1e-4
    assert abs(y2 - yaw) < 1e-4


def test_quat_unit_norm():
    q = rpy_deg_to_quaternion_xyzw(33.0, -17.0, 91.5)
    n = float(np.linalg.norm(q))
    assert abs(n - 1.0) < 1e-9
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_geometry_rpy.py -v Expected: FAIL (ImportError: cannot import name 'rpy_deg_to_quaternion_xyzw')

  • Step 3: Edit src/presa_mano/geometry.py

Append to the end of geometry.py:

def rpy_deg_to_quaternion_xyzw(roll_deg: float, pitch_deg: float, yaw_deg: float) -> np.ndarray:
    """Extrinsic XYZ Euler (deg) -> quaternion xyzw. Inverse of `quaternion_xyzw_to_rpy_deg`.
    Convenzione coerente con `R_to_euler_xyz` (XYZ extrinsic, in radianti)."""
    cr, sr = np.cos(np.radians(roll_deg) * 0.5), np.sin(np.radians(roll_deg) * 0.5)
    cp, sp = np.cos(np.radians(pitch_deg) * 0.5), np.sin(np.radians(pitch_deg) * 0.5)
    cy, sy = np.cos(np.radians(yaw_deg) * 0.5), np.sin(np.radians(yaw_deg) * 0.5)
    qw = cr * cp * cy + sr * sp * sy
    qx = sr * cp * cy - cr * sp * sy
    qy = cr * sp * cy + sr * cp * sy
    qz = cr * cp * sy - sr * sp * cy
    return np.array([qx, qy, qz, qw], dtype=np.float64)


def quaternion_xyzw_to_rpy_deg(quat_xyzw) -> tuple[float, float, float]:
    """Quaternion xyzw -> extrinsic XYZ Euler (deg). Convention matches `R_to_euler_xyz`."""
    R = quat_xyzw_to_R(np.asarray(quat_xyzw, dtype=np.float64))
    rpy_rad = R_to_euler_xyz(R)
    rpy_deg = np.degrees(rpy_rad)
    return float(rpy_deg[0]), float(rpy_deg[1]), float(rpy_deg[2])
  • Step 4: Run test to verify it passes

Run: uv run pytest tests/test_geometry_rpy.py -v Expected: PASS (8 test cases including parametrize)

  • Step 5: Commit
git add src/presa_mano/geometry.py tests/test_geometry_rpy.py
git commit -m "feat(geometry): aggiungi conversioni RPY (deg) ↔ quaternion xyzw"

Task 3: Aggiungere front_camera() e tab_right_arm_cfg() in _deps.py

Files:

  • Modify: src/presa_mano/api/_deps.py

  • Test: tests/test_deps_right_arm.py (nuovo)

  • Step 1: Write the failing test

Create tests/test_deps_right_arm.py:

"""Verifica che _deps esponga front_camera() e tab_right_arm_cfg()."""
from __future__ import annotations

from presa_mano.api import _deps
from presa_mano.clients import NuwaClient


def test_front_camera_returns_nuwa_client_with_config_base_url():
    cam = _deps.front_camera()
    assert isinstance(cam, NuwaClient)
    # base_url da config.yaml (stripped della trailing slash dal client)
    assert cam.base_url.startswith("http")
    # Deve essere DIVERSO dall'URL della nuwa principale
    assert cam.base_url != _deps.nuwa().base_url


def test_tab_right_arm_cfg_returns_dict_with_required_keys():
    cfg = _deps.tab_right_arm_cfg()
    assert cfg["yolo_class"] == "pestello"
    for key in ("yolo_conf_front", "yolo_conf_wrist", "move_offset_camera_mm",
                "axis_sign_cam_to_wrist", "max_step_m", "z_floor_m",
                "home_pose", "custom_pose"):
        assert key in cfg
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_deps_right_arm.py -v Expected: FAIL (AttributeError: module 'presa_mano.api._deps' has no attribute 'front_camera')

  • Step 3: Edit src/presa_mano/api/_deps.py

After the existing nuwa() function, add:

def front_camera() -> NuwaClient:
    """Camera depth montata sulla fronte del robot (servizio Nuwa-like in docker)."""
    c = _svc(get_config(), "front_camera")
    return NuwaClient(c["base_url"], timeout_s=c.get("timeout_s", 5.0))


def tab_right_arm_cfg() -> dict[str, Any]:
    """Blocco di config per la tab Braccio DX. Restituisce {} se mancante."""
    return get_config().get("tab_right_arm", {}) or {}
  • Step 4: Run test to verify it passes

Run: uv run pytest tests/test_deps_right_arm.py -v Expected: PASS (2 test)

  • Step 5: Commit
git add src/presa_mano/api/_deps.py tests/test_deps_right_arm.py
git commit -m "feat(deps): aggiungi front_camera() e tab_right_arm_cfg()"

Task 4: Pure math module right_arm/move.py

Files:

  • Create: src/presa_mano/right_arm/__init__.py

  • Create: src/presa_mano/right_arm/move.py

  • Test: tests/test_right_arm_move.py

  • Step 1: Write the failing test

Create tests/test_right_arm_move.py:

"""Unit test della logica pura del move-toward (no I/O)."""
from __future__ import annotations

import numpy as np
import pytest

from presa_mano.right_arm.move import (
    DeltaOversize,
    apply_z_floor,
    compute_delta_wrist,
)


def test_zero_offset_returns_centroid_with_signs():
    delta = compute_delta_wrist(
        centroid_camera_m=np.array([0.0, 0.0, 1.0]),
        offset_camera_m=np.array([0.0, 0.0, 0.0]),
        axis_signs=(1, 1, 1),
    )
    assert np.allclose(delta, [0.0, 0.0, 1.0])


def test_offset_subtracted_before_signs():
    # centroid=(0,0,1) offset=(0,0,0.1) sign=(1,-1,-1) → (0, 0, -0.9)
    delta = compute_delta_wrist(
        centroid_camera_m=np.array([0.0, 0.0, 1.0]),
        offset_camera_m=np.array([0.0, 0.0, 0.1]),
        axis_signs=(1, -1, -1),
    )
    assert np.allclose(delta, [0.0, 0.0, -0.9])


def test_signs_applied_componentwise():
    delta = compute_delta_wrist(
        centroid_camera_m=np.array([0.05, 0.10, 0.20]),
        offset_camera_m=np.array([0.00, 0.00, 0.00]),
        axis_signs=(1, -1, -1),
    )
    assert np.allclose(delta, [0.05, -0.10, -0.20])


def test_oversize_raises_when_not_forced():
    with pytest.raises(DeltaOversize):
        compute_delta_wrist(
            centroid_camera_m=np.array([0.0, 0.0, 0.5]),
            offset_camera_m=np.array([0.0, 0.0, 0.0]),
            axis_signs=(1, 1, 1),
            max_step_m=0.15,
            force=False,
        )


def test_oversize_passes_when_forced():
    delta = compute_delta_wrist(
        centroid_camera_m=np.array([0.0, 0.0, 0.5]),
        offset_camera_m=np.array([0.0, 0.0, 0.0]),
        axis_signs=(1, 1, 1),
        max_step_m=0.15,
        force=True,
    )
    assert np.allclose(delta, [0.0, 0.0, 0.5])


def test_z_floor_clips_when_target_below():
    # wrist_before.z=0.10, delta.z=-0.30, target=-0.20, floor=0.05 → clip a 0.05
    # → delta.z = 0.05 - 0.10 = -0.05
    new_delta, clipped = apply_z_floor(
        wrist_z_m=0.10,
        delta=np.array([0.01, -0.02, -0.30]),
        z_floor_m=0.05,
    )
    assert clipped is True
    assert new_delta[2] == pytest.approx(-0.05)
    # X/Y invariati
    assert new_delta[0] == pytest.approx(0.01)
    assert new_delta[1] == pytest.approx(-0.02)


def test_z_floor_noop_when_target_above():
    new_delta, clipped = apply_z_floor(
        wrist_z_m=0.30,
        delta=np.array([0.0, 0.0, -0.10]),
        z_floor_m=0.05,
    )
    assert clipped is False
    assert np.allclose(new_delta, [0.0, 0.0, -0.10])


def test_z_floor_none_disables_clip():
    new_delta, clipped = apply_z_floor(
        wrist_z_m=0.10,
        delta=np.array([0.0, 0.0, -0.50]),
        z_floor_m=None,
    )
    assert clipped is False
    assert np.allclose(new_delta, [0.0, 0.0, -0.50])
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_right_arm_move.py -v Expected: FAIL (ModuleNotFoundError: No module named 'presa_mano.right_arm')

  • Step 3: Create src/presa_mano/right_arm/__init__.py
"""Logica della tab Braccio DX: math puro + endpoint."""
  • Step 4: Create src/presa_mano/right_arm/move.py
"""Pure math: delta wrist a partire da centroide camera + offset + sign flips."""
from __future__ import annotations

import numpy as np


class DeltaOversize(ValueError):
    """Sollevata quando ||delta|| supera max_step_m e force=False."""

    def __init__(self, delta_norm_m: float, max_step_m: float):
        super().__init__(f"delta {delta_norm_m*1000:.1f}mm > max {max_step_m*1000:.1f}mm")
        self.delta_norm_m = float(delta_norm_m)
        self.max_step_m = float(max_step_m)


def compute_delta_wrist(
    centroid_camera_m: np.ndarray,
    offset_camera_m: np.ndarray,
    axis_signs: tuple[int, int, int],
    max_step_m: float | None = None,
    force: bool = False,
) -> np.ndarray:
    """Delta da applicare al polso (frame polso) per portare la camera sull'offset.

    Modello: si assume assi camera ≈ assi polso a meno dei segni.
        delta_wrist = sign * (centroid_camera - offset_camera)

    Se `max_step_m` è settato e ||delta|| > max_step_m e `force` è False, solleva
    DeltaOversize.
    """
    centroid = np.asarray(centroid_camera_m, dtype=np.float64).reshape(3)
    offset = np.asarray(offset_camera_m, dtype=np.float64).reshape(3)
    sign = np.asarray(axis_signs, dtype=np.float64).reshape(3)
    delta = sign * (centroid - offset)
    if max_step_m is not None and not force:
        n = float(np.linalg.norm(delta))
        if n > max_step_m:
            raise DeltaOversize(n, max_step_m)
    return delta


def apply_z_floor(
    wrist_z_m: float,
    delta: np.ndarray,
    z_floor_m: float | None,
) -> tuple[np.ndarray, bool]:
    """Se la Z target finirebbe sotto `z_floor_m`, clippa il delta.Z.
    Ritorna (nuovo_delta, was_clipped). `z_floor_m=None` disabilita il clip."""
    delta = np.asarray(delta, dtype=np.float64).reshape(3).copy()
    if z_floor_m is None:
        return delta, False
    target_z = float(wrist_z_m) + float(delta[2])
    if target_z >= float(z_floor_m):
        return delta, False
    delta[2] = float(z_floor_m) - float(wrist_z_m)
    return delta, True
  • Step 5: Run test to verify it passes

Run: uv run pytest tests/test_right_arm_move.py -v Expected: PASS (8 test)

  • Step 6: Commit
git add src/presa_mano/right_arm/ tests/test_right_arm_move.py
git commit -m "feat(right_arm): math puro per delta-toward (sign flips + z-floor)"

Task 5: Router skeleton + /state + /activate

Files:

  • Create: src/presa_mano/api/right_arm_routes.py

  • Modify: src/presa_mano/main.py

  • Test: tests/test_right_arm_routes.py

  • Step 1: Write the failing test

Create tests/test_right_arm_routes.py:

"""Integration test del router /api/right_arm/*."""
from __future__ import annotations

from unittest.mock import patch, MagicMock

from fastapi.testclient import TestClient

from presa_mano.main import app

client = TestClient(app)


def _fake_wrist_response():
    return {
        "left_wrist": {"position": [0.10, 0.20, 0.30], "quaternion": [0, 0, 0, 1]},
        "right_wrist": {"position": [0.30, -0.20, 0.30], "quaternion": [0, 0, 0, 1]},
    }


def test_state_returns_wrist_pose_and_config():
    with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_factory:
        g1 = MagicMock()
        g1.wrist.return_value = _fake_wrist_response()
        g1_factory.return_value = g1

        r = client.get("/api/right_arm/state")
        assert r.status_code == 200
        body = r.json()
        assert body["wrist"]["position"] == [0.30, -0.20, 0.30]
        assert "rpy_deg" in body["wrist"]
        assert body["side"] == "right" or body["side"] in ("left", "right")
        assert "home_pose" in body
        assert "custom_pose" in body
        assert "offset_camera_mm" in body
        assert "axis_signs" in body
        assert "max_step_m" in body
        assert "z_floor_m" in body
        assert "robot_active" in body
        assert "timings_ms" in body


def test_state_502_when_g1_unavailable():
    from presa_mano.clients import G1Unavailable
    with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_factory:
        g1 = MagicMock()
        g1.wrist.side_effect = G1Unavailable("connection refused")
        g1_factory.return_value = g1
        r = client.get("/api/right_arm/state")
        assert r.status_code == 502


def test_activate_sets_robot_active_g1_and_side_right():
    # /activate è idempotente: chiama PUT /api/config e ritorna /state
    with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_factory:
        g1 = MagicMock()
        g1.wrist.return_value = _fake_wrist_response()
        g1_factory.return_value = g1
        r = client.post("/api/right_arm/activate")
        assert r.status_code == 200
        body = r.json()
        assert body["robot_active"] == "g1"
        assert body["side"] == "right"
  • Step 2: Run test to verify it fails

Run: uv run pytest tests/test_right_arm_routes.py -v Expected: FAIL (404 Not Found perché il router non esiste ancora)

  • Step 3: Create src/presa_mano/api/right_arm_routes.py
"""Endpoint per la tab Braccio DX (G1, polso destro, Nuwa eye-in-hand)."""
from __future__ import annotations

from typing import Any

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from ..clients import G1Unavailable
from ..config import update_config
from ..geometry import quaternion_xyzw_to_rpy_deg
from ..timing import Stopwatch
from . import _deps

router = APIRouter()


def _state_payload(sw: Stopwatch) -> dict[str, Any]:
    cfg = _deps.tab_right_arm_cfg()
    with sw.step("g1_wrist"):
        wrist = _deps.g1().wrist()["right_wrist"]
    pos = wrist["position"]
    quat = wrist["quaternion"]
    rpy = quaternion_xyzw_to_rpy_deg(quat)
    return {
        "wrist": {"position": pos, "quaternion": quat,
                  "rpy_deg": [rpy[0], rpy[1], rpy[2]]},
        "robot_active": _deps.active_robot_key(),
        "side": _deps.default_side(),
        "home_pose": cfg.get("home_pose"),
        "custom_pose": cfg.get("custom_pose"),
        "offset_camera_mm": cfg.get("move_offset_camera_mm"),
        "axis_signs": cfg.get("axis_sign_cam_to_wrist"),
        "max_step_m": cfg.get("max_step_m"),
        "z_floor_m": cfg.get("z_floor_m"),
        "timings_ms": sw.as_dict(),
    }


@router.get("/state")
def get_state() -> dict[str, Any]:
    sw = Stopwatch()
    try:
        return _state_payload(sw)
    except G1Unavailable as e:
        raise HTTPException(status_code=502, detail=str(e))


@router.post("/activate")
def post_activate() -> dict[str, Any]:
    sw = Stopwatch()
    with sw.step("config_update"):
        update_config({"robot": {"active": "g1", "wrist_side": "right"}})
    try:
        return _state_payload(sw)
    except G1Unavailable as e:
        raise HTTPException(status_code=502, detail=str(e))
  • Step 4: Wire router in src/presa_mano/main.py

In the imports block, add right_arm_routes:

from .api import (
    agent_routes,
    calibration_routes,
    config_routes,
    grasp_routes,
    home_routes,
    models_routes,
    perception_routes,
    posture_routes,
    right_arm_routes,
    robot_routes,
    wrist_routes,
    work_plane_routes,
)

After the existing app.include_router(...) lines, add:

app.include_router(right_arm_routes.router, prefix="/api/right_arm", tags=["right_arm"])
  • Step 5: Run test to verify it passes

Run: uv run pytest tests/test_right_arm_routes.py -v Expected: PASS (3 test)

  • Step 6: Commit
git add src/presa_mano/api/right_arm_routes.py src/presa_mano/main.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): router skeleton con /state e /activate"

Task 6: /snapshot/front + /snapshot/wrist

Files:

  • Modify: src/presa_mano/api/right_arm_routes.py

  • Modify: tests/test_right_arm_routes.py

  • Step 1: Add failing tests

Append to tests/test_right_arm_routes.py:

def test_snapshot_front_returns_jpeg():
    fake_jpeg = b"\xff\xd8\xff" + b"\x00" * 100  # magic JPEG bytes
    with patch("presa_mano.api.right_arm_routes._deps.front_camera") as factory:
        cam = MagicMock()
        cam.rgb_jpeg.return_value = fake_jpeg
        factory.return_value = cam
        r = client.get("/api/right_arm/snapshot/front")
        assert r.status_code == 200
        assert r.headers["content-type"].startswith("image/jpeg")
        assert r.content == fake_jpeg


def test_snapshot_wrist_returns_jpeg():
    fake_jpeg = b"\xff\xd8\xff" + b"\x00" * 50
    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as factory:
        cam = MagicMock()
        cam.rgb_jpeg.return_value = fake_jpeg
        factory.return_value = cam
        r = client.get("/api/right_arm/snapshot/wrist")
        assert r.status_code == 200
        assert r.headers["content-type"].startswith("image/jpeg")
        assert r.content == fake_jpeg


def test_snapshot_front_502_on_failure():
    with patch("presa_mano.api.right_arm_routes._deps.front_camera") as factory:
        cam = MagicMock()
        cam.rgb_jpeg.side_effect = RuntimeError("connection refused")
        factory.return_value = cam
        r = client.get("/api/right_arm/snapshot/front")
        assert r.status_code == 502
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_right_arm_routes.py -v -k snapshot Expected: FAIL (404)

  • Step 3: Add endpoints to src/presa_mano/api/right_arm_routes.py

Add from fastapi.responses import Response to imports.

Append:

@router.get("/snapshot/front")
def snapshot_front() -> Response:
    try:
        jpeg = _deps.front_camera().rgb_jpeg()
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"front_camera unreachable: {e}")
    return Response(content=jpeg, media_type="image/jpeg")


@router.get("/snapshot/wrist")
def snapshot_wrist() -> Response:
    try:
        jpeg = _deps.nuwa().rgb_jpeg()
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"wrist nuwa unreachable: {e}")
    return Response(content=jpeg, media_type="image/jpeg")
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_right_arm_routes.py -v -k snapshot Expected: PASS (3 test)

  • Step 5: Commit
git add src/presa_mano/api/right_arm_routes.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): endpoint /snapshot/{front,wrist}"

Task 7: /detect/front con YOLO + proxy fallback

Files:

  • Modify: src/presa_mano/api/right_arm_routes.py

  • Modify: tests/test_right_arm_routes.py

  • Step 1: Add failing tests

Append:

def _fake_yolo_top(class_name="pestello", conf=0.87):
    return {"detections": [
        {"class": class_name, "confidence": conf,
         "bbox": {"x1": 100.0, "y1": 80.0, "x2": 220.0, "y2": 180.0}},
        {"class": "altra", "confidence": 0.5,
         "bbox": {"x1": 10.0, "y1": 10.0, "x2": 50.0, "y2": 50.0}},
    ]}


def test_detect_front_filters_class_and_returns_top():
    with patch("presa_mano.api.right_arm_routes._deps.yolo") as ying, \
         patch("presa_mano.api.right_arm_routes._deps.front_camera") as cam_f:
        yolo = MagicMock()
        yolo.detect_url.return_value = _fake_yolo_top()
        ying.return_value = yolo
        cam = MagicMock()
        cam.rgb_url.return_value = "http://192.168.123.99:8878/api/rgb"
        cam.intrinsics.return_value = {"width": 640, "height": 480}
        cam_f.return_value = cam

        r = client.post("/api/right_arm/detect/front",
                        json={"class_name": "pestello", "conf": 0.4})
        assert r.status_code == 200
        body = r.json()
        assert body["detections"][0]["class"] == "pestello"
        assert body["image_size"] == [640, 480]


def test_detect_front_404_when_no_match():
    with patch("presa_mano.api.right_arm_routes._deps.yolo") as ying, \
         patch("presa_mano.api.right_arm_routes._deps.front_camera") as cam_f:
        yolo = MagicMock()
        yolo.detect_url.return_value = {"detections": []}
        ying.return_value = yolo
        cam = MagicMock()
        cam.rgb_url.return_value = "http://x/api/rgb"
        cam.intrinsics.return_value = {"width": 640, "height": 480}
        cam_f.return_value = cam
        r = client.post("/api/right_arm/detect/front",
                        json={"class_name": "pestello"})
        assert r.status_code == 404


def test_detect_front_falls_back_to_proxy_url_on_yolo_failure():
    """Se YOLO non riesce sull'URL diretto, ripeti con `proxy_base_url`."""
    attempts = []

    def _detect_url(image_url, conf=None):
        attempts.append(image_url)
        if "192.168" in image_url:
            raise RuntimeError("dns timeout")
        return _fake_yolo_top()

    with patch("presa_mano.api.right_arm_routes._deps.yolo") as ying, \
         patch("presa_mano.api.right_arm_routes._deps.front_camera") as cam_f:
        yolo = MagicMock()
        yolo.detect_url.side_effect = _detect_url
        ying.return_value = yolo
        cam = MagicMock()
        cam.rgb_url.return_value = "http://192.168.123.99:8878/api/rgb"
        cam.intrinsics.return_value = {"width": 640, "height": 480}
        cam_f.return_value = cam

        r = client.post("/api/right_arm/detect/front",
                        json={"class_name": "pestello",
                              "proxy_base_url": "http://localhost:8890"})
        assert r.status_code == 200
        assert len(attempts) == 2
        assert "192.168" in attempts[0]
        assert attempts[1].endswith("/api/right_arm/snapshot/front")
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_right_arm_routes.py -v -k detect_front Expected: FAIL (404)

  • Step 3: Add endpoint to src/presa_mano/api/right_arm_routes.py

Add to imports:

from typing import Any, Optional
import logging

log = logging.getLogger(__name__)

Add Pydantic model and endpoint:

class DetectFrontReq(BaseModel):
    class_name: Optional[str] = None
    conf: Optional[float] = None
    proxy_base_url: Optional[str] = None  # se YOLO non raggiunge la front camera


def _yolo_detect_with_fallback(image_url: str, conf: float | None,
                                proxy_image_url: str | None, sw: Stopwatch
                                ) -> dict[str, Any]:
    """Prova YOLO sull'URL diretto; se fallisce e proxy_image_url è settato, ritenta."""
    yolo = _deps.yolo()
    try:
        with sw.step("yolo"):
            return yolo.detect_url(image_url=image_url, conf=conf)
    except Exception as e:
        if not proxy_image_url:
            raise HTTPException(status_code=502, detail=f"YOLO unreachable: {e}")
        log.warning("YOLO failed on direct URL (%s), retrying via proxy %s", e, proxy_image_url)
        try:
            with sw.step("yolo_proxy"):
                return yolo.detect_url(image_url=proxy_image_url, conf=conf)
        except Exception as e2:
            raise HTTPException(status_code=502, detail=f"YOLO unreachable (proxy): {e2}")


def _top_detection(payload: dict[str, Any], class_name: str | None) -> dict[str, Any] | None:
    dets = payload.get("detections") or payload.get("data", {}).get("detections") or []
    if class_name:
        dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()]
    if not dets:
        return None
    dets.sort(key=lambda d: float(d.get("confidence", 0.0)), reverse=True)
    return dets[0]


@router.post("/detect/front")
def detect_front(req: DetectFrontReq) -> dict[str, Any]:
    sw = Stopwatch()
    cam = _deps.front_camera()
    image_url = cam.rgb_url()
    proxy_url = None
    if req.proxy_base_url:
        proxy_url = f"{req.proxy_base_url.rstrip('/')}/api/right_arm/snapshot/front"
    res = _yolo_detect_with_fallback(image_url, req.conf, proxy_url, sw)
    dets_raw = res.get("detections") or res.get("data", {}).get("detections") or []
    if req.class_name:
        dets_filtered = [d for d in dets_raw
                         if (d.get("class") or "").lower() == req.class_name.lower()]
    else:
        dets_filtered = list(dets_raw)
    if not dets_filtered:
        raise HTTPException(status_code=404, detail=f"no '{req.class_name or 'object'}' detected")
    dets_filtered.sort(key=lambda d: float(d.get("confidence", 0.0)), reverse=True)
    intr = cam.intrinsics()
    return {
        "detections": dets_filtered,
        "image_size": [int(intr.get("width", 0)), int(intr.get("height", 0))],
        "timings_ms": sw.as_dict(),
    }
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_right_arm_routes.py -v -k detect_front Expected: PASS (3 test)

  • Step 5: Commit
git add src/presa_mano/api/right_arm_routes.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): /detect/front con proxy fallback per YOLO"

Task 8: /detect/wrist con YOLO + SAM21 + centroide

Files:

  • Modify: src/presa_mano/api/right_arm_routes.py

  • Modify: tests/test_right_arm_routes.py

  • Step 1: Add failing tests

Append:

def test_detect_wrist_returns_centroid_and_distance():
    # YOLO → bbox → SAM21 → centroide
    import numpy as np
    fake_points = np.array([
        [0.01, -0.02, 0.20],
        [0.02, -0.01, 0.21],
        [0.00, -0.03, 0.19],
    ] * 5, dtype=np.float64)  # 15 punti, > 10 minimo

    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f, \
         patch("presa_mano.api.right_arm_routes.segment_with_sam21") as seg_f, \
         patch("presa_mano.api.right_arm_routes._deps.sam21") as sam_f:
        nuwa = MagicMock()
        nuwa.rgb_url.return_value = "http://localhost:8877/api/rgb"
        nuwa_f.return_value = nuwa
        yolo = MagicMock()
        yolo.detect_url.return_value = _fake_yolo_top()
        yolo_f.return_value = yolo
        sam_f.return_value = MagicMock()
        seg = MagicMock()
        seg.points_cv_m = fake_points
        seg_f.return_value = seg

        r = client.post("/api/right_arm/detect/wrist",
                        json={"class_name": "pestello"})
        assert r.status_code == 200
        body = r.json()
        assert body["detection"]["class"] == "pestello"
        assert "bbox" in body
        c = body["centroid_camera_m"]
        assert abs(c[2] - 0.20) < 0.05
        assert body["distance_m"] > 0
        assert body["n_sam21_points"] == 15


def test_detect_wrist_422_too_few_points():
    import numpy as np
    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f, \
         patch("presa_mano.api.right_arm_routes.segment_with_sam21") as seg_f, \
         patch("presa_mano.api.right_arm_routes._deps.sam21") as sam_f:
        nuwa = MagicMock(); nuwa.rgb_url.return_value = "http://x"; nuwa_f.return_value = nuwa
        yolo = MagicMock(); yolo.detect_url.return_value = _fake_yolo_top(); yolo_f.return_value = yolo
        sam_f.return_value = MagicMock()
        seg = MagicMock(); seg.points_cv_m = np.zeros((3, 3))
        seg_f.return_value = seg
        r = client.post("/api/right_arm/detect/wrist",
                        json={"class_name": "pestello"})
        assert r.status_code == 422


def test_detect_wrist_404_when_yolo_no_match():
    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f:
        nuwa = MagicMock(); nuwa.rgb_url.return_value = "http://x"; nuwa_f.return_value = nuwa
        yolo = MagicMock(); yolo.detect_url.return_value = {"detections": []}; yolo_f.return_value = yolo
        r = client.post("/api/right_arm/detect/wrist", json={"class_name": "pestello"})
        assert r.status_code == 404
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_right_arm_routes.py -v -k detect_wrist Expected: FAIL (404)

  • Step 3: Add endpoint

Add imports at top of right_arm_routes.py:

import numpy as np
from ..perception.sam21_bridge import Sam21Fallback, segment_with_sam21
from .perception_routes import _bbox_to_roi

Add model and endpoint:

class DetectWristReq(BaseModel):
    class_name: Optional[str] = None
    conf: Optional[float] = None


def _detect_wrist_core(class_name: str | None, conf: float | None, sw: Stopwatch
                       ) -> dict[str, Any]:
    """Funzione core riusata da /detect/wrist e da /move/toward."""
    nuwa = _deps.nuwa()
    image_url = nuwa.rgb_url()
    try:
        with sw.step("yolo"):
            yres = _deps.yolo().detect_url(image_url=image_url, conf=conf)
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"YOLO unreachable: {e}")
    top = _top_detection(yres, class_name)
    if top is None:
        raise HTTPException(status_code=404, detail=f"no '{class_name or 'object'}' detected")
    bbox = _bbox_to_roi(top["bbox"])
    try:
        seg = segment_with_sam21(nuwa, _deps.sam21(), bbox, sw)
    except Sam21Fallback as e:
        raise HTTPException(status_code=422, detail=f"SAM21 fallback: {e}")
    pts = np.asarray(seg.points_cv_m, dtype=np.float64)
    if pts.shape[0] < 10:
        raise HTTPException(status_code=422, detail=f"too few sam21 points: {pts.shape[0]}")
    with sw.step("centroid"):
        centroid = pts.mean(axis=0)
        distance = float(np.linalg.norm(centroid))
    return {
        "detection": top,
        "bbox": list(bbox),
        "centroid_camera_m": [float(centroid[0]), float(centroid[1]), float(centroid[2])],
        "distance_m": distance,
        "n_sam21_points": int(pts.shape[0]),
    }


@router.post("/detect/wrist")
def detect_wrist(req: DetectWristReq) -> dict[str, Any]:
    sw = Stopwatch()
    body = _detect_wrist_core(req.class_name, req.conf, sw)
    body["timings_ms"] = sw.as_dict()
    return body
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_right_arm_routes.py -v -k detect_wrist Expected: PASS (3 test)

  • Step 5: Commit
git add src/presa_mano/api/right_arm_routes.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): /detect/wrist con YOLO + SAM21 + centroide camera"

Task 9: /move/toward — endpoint critico

Files:

  • Modify: src/presa_mano/api/right_arm_routes.py

  • Modify: tests/test_right_arm_routes.py

  • Step 1: Add failing tests

Append:

def _patch_full_detect_chain(centroid_m=(0.0, 0.0, 0.20), n_points=20):
    """Helper: patcha yolo + sam21 bridge per il /move/toward."""
    import numpy as np
    seg_pts = np.tile(np.array(centroid_m, dtype=np.float64), (n_points, 1))
    return seg_pts


def test_move_toward_dry_run_returns_delta_no_call_to_g1():
    import numpy as np
    seg_pts = _patch_full_detect_chain(centroid_m=(0.0, 0.0, 0.20), n_points=20)
    g1_called = []

    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f, \
         patch("presa_mano.api.right_arm_routes.segment_with_sam21") as seg_f, \
         patch("presa_mano.api.right_arm_routes._deps.sam21"), \
         patch("presa_mano.api.right_arm_routes._deps.g1") as g1_f:
        nuwa_f.return_value.rgb_url.return_value = "http://x"
        yolo_f.return_value.detect_url.return_value = _fake_yolo_top()
        seg_f.return_value = MagicMock(points_cv_m=seg_pts)
        g1 = MagicMock()
        g1.wrist.return_value = _fake_wrist_response()

        def _track_relative(**kw):
            g1_called.append(kw)
            return {"ok": True}
        g1.wrist_relative.side_effect = _track_relative
        g1_f.return_value = g1

        r = client.post("/api/right_arm/move/toward",
                        json={"class_name": "pestello", "dry_run": True})
        assert r.status_code == 200
        body = r.json()
        # offset default (0,0,100mm) → centroid - offset = (0, 0, 0.10) →
        # con sign (1,-1,-1) → (0, 0, -0.10) m
        assert abs(body["delta_wrist_m"][2] - (-0.10)) < 1e-6
        assert body["applied"] is False
        # Anche se dry_run=true, il G1Client viene comunque chiamato per la
        # lettura della posa wrist (g1.wrist()) ma con dry_run=True il
        # _post viene short-circuited; verifichiamo che wrist_relative sia
        # comunque chiamato (G1Client gestisce dry-run internamente)
        # NOTA: g1.wrist_relative viene comunque invocato perché lo dry-run
        # è gestito nel client G1, non saltato dall'endpoint.
        # Quindi g1_called avrà 1 entry, ma il client non avrebbe fatto HTTP.
        assert len(g1_called) == 1
        assert g1_called[0]["side"] == "right"


def test_move_toward_real_call_returns_wrist_after():
    import numpy as np
    seg_pts = _patch_full_detect_chain(centroid_m=(0.0, 0.0, 0.20), n_points=20)
    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f, \
         patch("presa_mano.api.right_arm_routes.segment_with_sam21") as seg_f, \
         patch("presa_mano.api.right_arm_routes._deps.sam21"), \
         patch("presa_mano.api.right_arm_routes._deps.g1") as g1_f:
        nuwa_f.return_value.rgb_url.return_value = "http://x"
        yolo_f.return_value.detect_url.return_value = _fake_yolo_top()
        seg_f.return_value = MagicMock(points_cv_m=seg_pts)
        g1 = MagicMock()
        g1.wrist.return_value = _fake_wrist_response()
        g1.wrist_relative.return_value = {"ok": True}
        g1_f.return_value = g1
        r = client.post("/api/right_arm/move/toward",
                        json={"class_name": "pestello", "dry_run": False})
        assert r.status_code == 200
        body = r.json()
        assert body["applied"] is True
        assert body["wrist_after"] is not None


def test_move_toward_422_oversize_delta_without_force():
    import numpy as np
    # centroid=(0,0,0.50), default offset=(0,0,0.10) → delta_z=-0.40 → > max_step 0.15
    seg_pts = _patch_full_detect_chain(centroid_m=(0.0, 0.0, 0.50), n_points=20)
    with patch("presa_mano.api.right_arm_routes._deps.nuwa") as nuwa_f, \
         patch("presa_mano.api.right_arm_routes._deps.yolo") as yolo_f, \
         patch("presa_mano.api.right_arm_routes.segment_with_sam21") as seg_f, \
         patch("presa_mano.api.right_arm_routes._deps.sam21"), \
         patch("presa_mano.api.right_arm_routes._deps.g1") as g1_f:
        nuwa_f.return_value.rgb_url.return_value = "http://x"
        yolo_f.return_value.detect_url.return_value = _fake_yolo_top()
        seg_f.return_value = MagicMock(points_cv_m=seg_pts)
        g1 = MagicMock(); g1.wrist.return_value = _fake_wrist_response(); g1_f.return_value = g1
        r = client.post("/api/right_arm/move/toward",
                        json={"class_name": "pestello", "dry_run": True, "force": False})
        assert r.status_code == 422
        assert "delta" in r.json()["detail"].lower()


def test_move_toward_412_when_not_g1_right():
    """Se config attiva != g1 o side != right, rifiuta con 412."""
    import numpy as np
    seg_pts = _patch_full_detect_chain(n_points=20)
    with patch("presa_mano.api.right_arm_routes._deps.active_robot_key", return_value="h1"), \
         patch("presa_mano.api.right_arm_routes._deps.default_side", return_value="left"):
        r = client.post("/api/right_arm/move/toward",
                        json={"class_name": "pestello", "dry_run": True})
        assert r.status_code == 412
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_right_arm_routes.py -v -k move_toward Expected: FAIL (404 sui primi 3, eventualmente 404/500 sul 412)

  • Step 3: Add endpoint to right_arm_routes.py

Add to imports:

from ..right_arm.move import DeltaOversize, apply_z_floor, compute_delta_wrist

Add Pydantic model and endpoint:

class MoveTowardReq(BaseModel):
    class_name: Optional[str] = None
    conf: Optional[float] = None
    dry_run: bool = True
    force: bool = False


def _guard_g1_right() -> None:
    if _deps.active_robot_key() != "g1":
        raise HTTPException(status_code=412, detail=f"activate G1 right first (active={_deps.active_robot_key()})")
    if _deps.default_side() != "right":
        raise HTTPException(status_code=412, detail=f"activate G1 right first (side={_deps.default_side()})")


@router.post("/move/toward")
def move_toward(req: MoveTowardReq) -> dict[str, Any]:
    sw = Stopwatch()
    _guard_g1_right()
    cfg = _deps.tab_right_arm_cfg()
    off_mm = cfg.get("move_offset_camera_mm", {"x": 0, "y": 0, "z": 0})
    sign = cfg.get("axis_sign_cam_to_wrist", {"x": 1, "y": 1, "z": 1})
    max_step_m = float(cfg.get("max_step_m", 0.15))
    z_floor = cfg.get("z_floor_m")
    z_floor_m = float(z_floor) if z_floor is not None else None

    det = _detect_wrist_core(req.class_name, req.conf, sw)
    centroid = np.asarray(det["centroid_camera_m"], dtype=np.float64)
    offset_m = np.array([off_mm["x"], off_mm["y"], off_mm["z"]], dtype=np.float64) / 1000.0
    signs = (int(sign["x"]), int(sign["y"]), int(sign["z"]))

    try:
        delta = compute_delta_wrist(centroid, offset_m, signs, max_step_m=max_step_m, force=req.force)
    except DeltaOversize as e:
        raise HTTPException(status_code=422, detail=str(e))

    # Leggi posa polso PRIMA del move per applicare z-floor
    try:
        with sw.step("g1_wrist_before"):
            wrist_before = _deps.g1().wrist()["right_wrist"]
    except G1Unavailable as e:
        raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")

    delta, z_clipped = apply_z_floor(float(wrist_before["position"][2]), delta, z_floor_m)

    try:
        with sw.step("wrist_relative"):
            _deps.g1(dry_run=req.dry_run).wrist_relative(
                side="right", delta_position=delta.tolist(),
            )
    except G1Unavailable as e:
        raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")

    wrist_after: dict[str, Any] | None = None
    if not req.dry_run:
        try:
            with sw.step("g1_wrist_after"):
                wrist_after = _deps.g1().wrist()["right_wrist"]
        except G1Unavailable:
            wrist_after = None  # best-effort; il move è stato già inviato

    log.info(
        "move_toward class=%s conf=%s centroid=%s offset=%s sign=%s delta=%s dry_run=%s applied=%s",
        req.class_name, req.conf, centroid.tolist(),
        offset_m.tolist(), signs, delta.tolist(), req.dry_run, not req.dry_run,
    )

    return {
        "detection": det["detection"],
        "centroid_camera_m": det["centroid_camera_m"],
        "distance_m": det["distance_m"],
        "offset_camera_m": offset_m.tolist(),
        "axis_signs": list(signs),
        "delta_wrist_m": delta.tolist(),
        "delta_wrist_mm": (delta * 1000.0).tolist(),
        "z_clipped": bool(z_clipped),
        "wrist_before": wrist_before,
        "wrist_after": wrist_after,
        "applied": not req.dry_run,
        "n_sam21_points": det["n_sam21_points"],
        "timings_ms": sw.as_dict(),
    }
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_right_arm_routes.py -v -k move_toward Expected: PASS (4 test)

  • Step 5: Commit
git add src/presa_mano/api/right_arm_routes.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): /move/toward con sign flips, max-step e z-floor"

Task 10: /home, /custom_pose, PUT /custom_pose, PUT /offset

Files:

  • Modify: src/presa_mano/api/right_arm_routes.py

  • Modify: tests/test_right_arm_routes.py

  • Step 1: Add failing tests

Append:

def test_home_calls_wrist_absolute_with_home_pose():
    calls = []
    def _abs(**kw):
        calls.append(kw)
        return {"ok": True}
    with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_f, \
         patch("presa_mano.api.right_arm_routes._deps.active_robot_key", return_value="g1"), \
         patch("presa_mano.api.right_arm_routes._deps.default_side", return_value="right"):
        g1 = MagicMock(); g1.wrist_absolute.side_effect = _abs; g1_f.return_value = g1
        r = client.post("/api/right_arm/home", json={"dry_run": True})
        assert r.status_code == 200
        assert len(calls) == 1
        assert calls[0]["side"] == "right"
        assert len(calls[0]["position"]) == 3
        assert len(calls[0]["quaternion"]) == 4


def test_custom_pose_executes_pose_with_dry_run_default_false():
    calls = []
    def _abs(**kw):
        calls.append(kw); return {"ok": True}
    with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_f, \
         patch("presa_mano.api.right_arm_routes._deps.active_robot_key", return_value="g1"), \
         patch("presa_mano.api.right_arm_routes._deps.default_side", return_value="right"):
        g1 = MagicMock(); g1.wrist_absolute.side_effect = _abs; g1_f.return_value = g1
        r = client.post("/api/right_arm/custom_pose", json={})
        assert r.status_code == 200
        assert len(calls) == 1


def test_put_custom_pose_updates_config():
    new_pos = [0.40, -0.15, 0.25]
    new_rpy = [10.0, -5.0, 90.0]
    r = client.put("/api/right_arm/custom_pose",
                   json={"position": new_pos, "rpy_deg": new_rpy})
    assert r.status_code == 200
    r2 = client.get("/api/config")
    cp = r2.json()["tab_right_arm"]["custom_pose"]
    assert cp["position"] == new_pos
    assert cp["rpy_deg"] == new_rpy


def test_put_offset_updates_config():
    r = client.put("/api/right_arm/offset",
                   json={"move_offset_camera_mm": {"x": 5, "y": -3, "z": 75},
                         "axis_sign_cam_to_wrist": {"x": -1, "y": 1, "z": -1}})
    assert r.status_code == 200
    r2 = client.get("/api/config")
    body = r2.json()["tab_right_arm"]
    assert body["move_offset_camera_mm"] == {"x": 5, "y": -3, "z": 75}
    assert body["axis_sign_cam_to_wrist"] == {"x": -1, "y": 1, "z": -1}
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_right_arm_routes.py -v -k "home or custom_pose or offset" Expected: FAIL (404)

  • Step 3: Add endpoints

Add to imports:

from ..geometry import rpy_deg_to_quaternion_xyzw

Append:

class PoseExecReq(BaseModel):
    dry_run: bool = False


class PoseUpdateReq(BaseModel):
    position: Optional[list[float]] = None
    rpy_deg: Optional[list[float]] = None


class OffsetUpdateReq(BaseModel):
    move_offset_camera_mm: Optional[dict[str, float]] = None
    axis_sign_cam_to_wrist: Optional[dict[str, int]] = None


def _exec_named_pose(pose_key: str, dry_run: bool, sw: Stopwatch) -> dict[str, Any]:
    _guard_g1_right()
    cfg = _deps.tab_right_arm_cfg()
    pose = cfg.get(pose_key) or {}
    pos = pose.get("position")
    rpy = pose.get("rpy_deg")
    if not (pos and rpy and len(pos) == 3 and len(rpy) == 3):
        raise HTTPException(status_code=422, detail=f"{pose_key} malformed in config")
    quat = rpy_deg_to_quaternion_xyzw(rpy[0], rpy[1], rpy[2])
    try:
        with sw.step("wrist_absolute"):
            res = _deps.g1(dry_run=dry_run).wrist_absolute(
                side="right", position=list(pos), quaternion=quat.tolist(),
            )
    except G1Unavailable as e:
        raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
    return {"pose_key": pose_key, "position": pos, "rpy_deg": rpy,
            "quaternion": quat.tolist(), "result": res, "dry_run": dry_run,
            "applied": not dry_run, "timings_ms": sw.as_dict()}


@router.post("/home")
def post_home(req: PoseExecReq) -> dict[str, Any]:
    return _exec_named_pose("home_pose", req.dry_run, Stopwatch())


@router.post("/custom_pose")
def post_custom_pose(req: PoseExecReq) -> dict[str, Any]:
    return _exec_named_pose("custom_pose", req.dry_run, Stopwatch())


@router.put("/custom_pose")
def put_custom_pose(req: PoseUpdateReq) -> dict[str, Any]:
    patch: dict[str, Any] = {}
    if req.position is not None:
        if len(req.position) != 3:
            raise HTTPException(status_code=422, detail="position must be 3 floats")
        patch["position"] = list(req.position)
    if req.rpy_deg is not None:
        if len(req.rpy_deg) != 3:
            raise HTTPException(status_code=422, detail="rpy_deg must be 3 floats")
        patch["rpy_deg"] = list(req.rpy_deg)
    if not patch:
        raise HTTPException(status_code=422, detail="nothing to update")
    update_config({"tab_right_arm": {"custom_pose": patch}})
    return {"custom_pose": _deps.tab_right_arm_cfg().get("custom_pose")}


@router.put("/offset")
def put_offset(req: OffsetUpdateReq) -> dict[str, Any]:
    patch: dict[str, Any] = {}
    if req.move_offset_camera_mm is not None:
        for k in ("x", "y", "z"):
            if k not in req.move_offset_camera_mm:
                raise HTTPException(status_code=422, detail=f"move_offset_camera_mm missing '{k}'")
        patch["move_offset_camera_mm"] = {k: float(req.move_offset_camera_mm[k]) for k in ("x", "y", "z")}
    if req.axis_sign_cam_to_wrist is not None:
        for k in ("x", "y", "z"):
            v = req.axis_sign_cam_to_wrist.get(k)
            if v not in (-1, 1):
                raise HTTPException(status_code=422, detail=f"axis_sign_cam_to_wrist['{k}'] must be +/-1")
        patch["axis_sign_cam_to_wrist"] = {k: int(req.axis_sign_cam_to_wrist[k]) for k in ("x", "y", "z")}
    if not patch:
        raise HTTPException(status_code=422, detail="nothing to update")
    update_config({"tab_right_arm": patch})
    cfg = _deps.tab_right_arm_cfg()
    return {"move_offset_camera_mm": cfg.get("move_offset_camera_mm"),
            "axis_sign_cam_to_wrist": cfg.get("axis_sign_cam_to_wrist")}
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/test_right_arm_routes.py -v -k "home or custom_pose or offset" Expected: PASS (4 test)

  • Step 5: Run full test file to check nothing regressed

Run: uv run pytest tests/test_right_arm_routes.py -v Expected: PASS (tutti i test del file)

  • Step 6: Commit
git add src/presa_mano/api/right_arm_routes.py tests/test_right_arm_routes.py
git commit -m "feat(right_arm): endpoint /home, /custom_pose (POST+PUT), PUT /offset"

Task 11: Markup HTML della tab right-arm

Files:

  • Modify: web/index.html

  • Step 1: Aggiungi entry alla nav

Locate the line:

    <button class="tab" data-tab="ee">Movimento EE</button>

Insert BEFORE it:

    <button class="tab" data-tab="right-arm">Braccio DX</button>
  • Step 2: Aggiungi il pannello della tab

Locate the line <section class="panel" id="tab-ee"> and insert BEFORE it:

<section class="panel" id="tab-right-arm">
  <div id="raStatusBanner" class="ra-banner ra-banner-warn" hidden>
    <span id="raStatusLabel">Braccio DX non attivo</span>
    <button id="raActivateBtn" type="button">Attiva G1 right</button>
  </div>

  <div class="ra-grid">
    <!-- FRONTE -->
    <div class="ra-card">
      <h3>Fronte</h3>
      <div class="ra-imgwrap">
        <img id="raFrontImg" alt="front camera" />
        <canvas id="raFrontCanvas"></canvas>
      </div>
      <div class="ra-row">
        <label>Classe <select id="raFrontClass"></select></label>
        <label>Conf <input id="raFrontConf" type="number" step="0.05" min="0" max="1" value="0.4" /></label>
      </div>
      <div class="ra-row">
        <button id="raFrontDetectBtn" type="button">Cerca su fronte</button>
        <label><input id="raFrontAutoRefresh" type="checkbox" checked /> auto-refresh 1s</label>
      </div>
      <div id="raFrontResult" class="ra-result dim">— nessuna detection</div>
    </div>

    <!-- POLSO -->
    <div class="ra-card">
      <h3>Polso (Nuwa)</h3>
      <div class="ra-imgwrap">
        <img id="raWristImg" alt="wrist camera" />
        <canvas id="raWristCanvas"></canvas>
      </div>
      <div class="ra-row">
        <label>Classe <select id="raWristClass"></select></label>
        <label>Conf <input id="raWristConf" type="number" step="0.05" min="0" max="1" value="0.4" /></label>
      </div>
      <div class="ra-row">
        <button id="raWristDetectBtn" type="button">Cerca su polso</button>
        <label><input id="raWristAutoRefresh" type="checkbox" checked /> auto-refresh 1s</label>
      </div>
      <div id="raWristResult" class="ra-result dim">— nessuna detection</div>
    </div>

    <!-- POSA POLSO -->
    <div class="ra-card">
      <h3>Posa polso DX</h3>
      <table class="ra-pose-table" id="raPoseTable">
        <tr><th>X</th><td id="raPoseX">—</td><td>m</td></tr>
        <tr><th>Y</th><td id="raPoseY">—</td><td>m</td></tr>
        <tr><th>Z</th><td id="raPoseZ">—</td><td>m</td></tr>
        <tr><th>Roll</th><td id="raPoseR">—</td><td>°</td></tr>
        <tr><th>Pitch</th><td id="raPoseP">—</td><td>°</td></tr>
        <tr><th>Yaw</th><td id="raPoseW">—</td><td>°</td></tr>
      </table>
      <button id="raPoseRefreshBtn" type="button">Refresh</button>
    </div>
  </div>

  <!-- OFFSET + MOVE -->
  <div class="ra-card ra-wide">
    <h3>Offset presa rispetto a camera (mm)</h3>
    <div class="ra-row">
      <label>X <input id="raOffX" type="number" step="1" value="0" /></label>
      <label>Y <input id="raOffY" type="number" step="1" value="0" /></label>
      <label>Z <input id="raOffZ" type="number" step="1" value="100" /></label>
      <span class="ra-sep">|</span>
      <label>Sign X <input id="raSgnX" type="number" step="2" min="-1" max="1" value="1" /></label>
      <label>Sign Y <input id="raSgnY" type="number" step="2" min="-1" max="1" value="-1" /></label>
      <label>Sign Z <input id="raSgnZ" type="number" step="2" min="-1" max="1" value="-1" /></label>
    </div>
    <div class="ra-row">
      <button id="raSaveOffsetBtn" type="button">Salva offset</button>
      <label><input id="raMoveDryRun" type="checkbox" checked /> dry-run</label>
      <button id="raMoveBtn" type="button" class="ra-danger">Muovi verso pezzo</button>
    </div>
    <div id="raMoveResult" class="ra-result dim">— nessun move</div>
  </div>

  <!-- HOME + CUSTOM -->
  <div class="ra-row ra-wide-grid">
    <div class="ra-card">
      <h3>Posa Home (preset)</h3>
      <div class="ra-pose-form">
        <label>X <input id="raHomeX" type="number" step="0.01" /></label>
        <label>Y <input id="raHomeY" type="number" step="0.01" /></label>
        <label>Z <input id="raHomeZ" type="number" step="0.01" /></label>
        <label>Roll <input id="raHomeR" type="number" step="1" /></label>
        <label>Pitch <input id="raHomeP" type="number" step="1" /></label>
        <label>Yaw <input id="raHomeW" type="number" step="1" /></label>
      </div>
      <button id="raHomeBtn" type="button">Vai a Home</button>
    </div>

    <div class="ra-card">
      <h3>Posa Custom (editabile)</h3>
      <div class="ra-pose-form">
        <label>X <input id="raCustX" type="number" step="0.01" /></label>
        <label>Y <input id="raCustY" type="number" step="0.01" /></label>
        <label>Z <input id="raCustZ" type="number" step="0.01" /></label>
        <label>Roll <input id="raCustR" type="number" step="1" /></label>
        <label>Pitch <input id="raCustP" type="number" step="1" /></label>
        <label>Yaw <input id="raCustW" type="number" step="1" /></label>
      </div>
      <div class="ra-row">
        <button id="raCustomGoBtn" type="button">Vai a Custom</button>
        <button id="raCustomSaveBtn" type="button">Salva custom</button>
        <button id="raCustomUseCurrentBtn" type="button">Usa posa corrente</button>
      </div>
    </div>
  </div>
</section>
  • Step 3: Aggiungi lo script include

Locate:

<script type="module" src="/static/work_plane.js"></script>

Add immediately after:

<script type="module" src="/static/right_arm.js"></script>
  • Step 4: Aggiungi gli stili CSS minimi

Append to web/style.css:

/* ----- Tab Braccio DX ----- */
.ra-banner {
  padding: 8px 12px;
  border-radius: 4px;
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  gap: 12px;
}
.ra-banner-warn { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.ra-banner-ok   { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.ra-grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 12px;
  margin-bottom: 12px;
}
.ra-card {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
}
.ra-card h3 { margin-top: 0; }
.ra-card.ra-wide { grid-column: 1 / -1; }
.ra-imgwrap { position: relative; width: 100%; }
.ra-imgwrap img { width: 100%; display: block; }
.ra-imgwrap canvas { position: absolute; top: 0; left: 0; pointer-events: none; width: 100%; height: 100%; }
.ra-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 6px 0; }
.ra-sep { color: #888; }
.ra-result { font-family: monospace; font-size: 0.9em; padding: 6px; background: #f0f0f0; border-radius: 4px; }
.ra-pose-table { border-collapse: collapse; }
.ra-pose-table th, .ra-pose-table td { padding: 2px 8px; text-align: left; }
.ra-pose-form { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin-bottom: 8px; }
.ra-pose-form input { width: 100%; }
.ra-wide-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ra-danger { background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; }
.ra-danger:hover { background: #c82333; }
.dim { color: #888; }
  • Step 5: Commit
git add web/index.html web/style.css
git commit -m "feat(gui): markup + CSS tab Braccio DX"

Task 12: JS module web/right_arm.js

Files:

  • Create: web/right_arm.js

  • Step 1: Create the file

// 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) {
    $('raFrontConf').value = cfg.tab_right_arm.yolo_conf_front ?? 0.4;
    $('raWristConf').value = cfg.tab_right_arm.yolo_conf_wrist ?? 0.4;
  }
}

// ---------------- 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();
}
  • Step 2: Update cache-busting in src/presa_mano/main.py

Locate:

    for name in ("app.js", "work_plane.js", "style.css"):

Change to:

    for name in ("app.js", "work_plane.js", "right_arm.js", "style.css"):
  • Step 3: Smoke test (manual)
# In un terminale separato:
uv run uvicorn presa_mano.main:app --host 0.0.0.0 --port 8890 --reload

In browser: http://localhost:8890. Click sulla tab "Braccio DX". Verifica:

  • Banner stato visibile (verde se già g1/right, giallo altrimenti).

  • Immagine fronte si aggiorna (placeholder OK se la front camera non è raggiungibile dal browser/orchestrator — controllare console).

  • Immagine polso si aggiorna.

  • Click "Refresh" sulla posa polso → valori numerici (se G1 disponibile).

  • Pulsanti Home / Custom non testati senza robot fisico.

  • Step 4: Commit

git add web/right_arm.js src/presa_mano/main.py
git commit -m "feat(gui): right_arm.js — auto-refresh, detect, move, home, custom"

Task 13: Run full test suite

Files: —

  • Step 1: Esegui tutti i test del progetto

Run: uv run pytest -v Expected: tutti i test esistenti PASS + i 25+ nuovi test della tab Braccio DX PASS.

  • Step 2: Se qualcosa rompe, fix e commit

I test esistenti non dovrebbero essere toccati da queste modifiche (router nuovo, nessuna firma esistente cambiata). Se rompono, è probabile un side-effect — investigare e fixare con un commit dedicato.

  • Step 3: No commit se tutto passa al primo colpo

Self-Review

1. Spec coverage:

  • ✅ Front camera reuso NuwaClient → Task 3
  • ✅ Config tab_right_arm + services.front_camera → Task 1
  • ✅ Conversioni RPY ↔ quaternion → Task 2
  • ✅ Modulo math puro right_arm/move.py → Task 4
  • ✅ Router /state, /activate → Task 5
  • ✅ /snapshot/{front,wrist} → Task 6
  • ✅ /detect/front con proxy fallback → Task 7
  • ✅ /detect/wrist con YOLO+SAM21 → Task 8
  • ✅ /move/toward con sign flips, max-step, z-floor → Task 9
  • ✅ /home, /custom_pose (POST), PUT /custom_pose, PUT /offset → Task 10
  • ✅ Wire router in main.py → Task 5
  • ✅ Markup HTML → Task 11
  • ✅ JS module → Task 12
  • ✅ Cache-busting JS → Task 12
  • ✅ Test geometry + move (unit) + routes (integration) → Task 2, 4, 5-10
  • ✅ Smoke test manuale → Task 12

2. Placeholder scan:

  • Nessun TBD, TODO, "implement later". Step 3 di Task 2 ha una nota "Note: the line with cx if False else ..." che spiega ed elimina inline il typo — l'engineer scrive la riga corretta qz = cr * cp * sy - sr * sp * cy. Coverage del test parametrico cattura comunque l'errore.

3. Type consistency:

  • compute_delta_wrist(centroid, offset, axis_signs, max_step_m, force) — firma costante tra Task 4 (definizione) e Task 9 (uso).
  • apply_z_floor(wrist_z_m, delta, z_floor_m) -> (delta, clipped) — coerente.
  • rpy_deg_to_quaternion_xyzw(roll, pitch, yaw) — coerente.
  • _detect_wrist_core(class_name, conf, sw) definita in Task 8, usata in Task 9.
  • _top_detection(payload, class_name) definita in Task 7, usata in Task 8 (_detect_wrist_core).
  • _guard_g1_right() definita in Task 9, usata anche in Task 10 (_exec_named_pose).
  • _state_payload(sw) definita in Task 5, usata da /state e /activate.
  • IDs HTML coerenti tra index.html (Task 11) e right_arm.js (Task 12): raStatusBanner, raFrontImg, ecc.

Nessuna inconsistenza trovata.